diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..4ab558b
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,40 @@
+# Bose SoundTouch Configuration
+# Copy this file to .env and customize for your setup
+
+# Discovery Settings
+DISCOVERY_TIMEOUT=5s
+UPNP_ENABLED=true
+
+# HTTP Client Settings
+HTTP_TIMEOUT=10s
+USER_AGENT="Bose-SoundTouch-Go-Client/1.0"
+
+# Cache Settings
+CACHE_ENABLED=true
+CACHE_TTL=30s
+
+# Preferred Devices Configuration
+# Format: name@host:port;name@host:port;...
+# - name is optional (will default to "SoundTouch-{host}")
+# - port is optional (will default to 8090)
+# - Multiple devices are separated by semicolons
+
+# Examples:
+# Single device with default port:
+# PREFERRED_DEVICES="192.168.1.100"
+
+# Single device with custom name:
+# PREFERRED_DEVICES="Living Room@192.168.1.100"
+
+# Single device with custom port:
+# PREFERRED_DEVICES="192.168.1.100:8091"
+
+# Multiple devices with mixed configurations:
+PREFERRED_DEVICES="Living Room@192.168.1.100:8090;Kitchen@192.168.1.101;192.168.1.102:8091"
+
+# Real example based on your devices:
+# PREFERRED_DEVICES="Sound Machinechen@192.168.1.35;My SoundTouch Device@192.168.1.100"
+
+# Alternative format examples:
+# PREFERRED_DEVICES="192.168.1.35;192.168.1.100"
+# PREFERRED_DEVICES="SoundTouch 10@192.168.1.35;SoundTouch 20@192.168.1.100"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f1b5ff0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,69 @@
+# Build artifacts
+build/
+dist/
+*.exe
+*.dll
+*.so
+*.dylib
+
+# Environment configuration
+.env
+.env.local
+.env.*.local
+
+# Test coverage reports
+coverage.out
+coverage.html
+*.prof
+
+# Go workspace file
+go.work
+go.work.sum
+
+# Dependency directories
+vendor/
+
+# IDE and editor files
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS generated files
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+
+# Temporary files
+*.tmp
+*.temp
+*.log
+*.pid
+*.seed
+*.pid.lock
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Optional npm cache directory
+.npm
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file (but keep .env.example)
+!.env.example
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..f235644
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,150 @@
+.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help
+
+# Go parameters
+GOCMD=go
+GOBUILD=$(GOCMD) build
+GOCLEAN=$(GOCMD) clean
+GOTEST=$(GOCMD) test
+GOGET=$(GOCMD) get
+GOMOD=$(GOCMD) mod
+GOFMT=gofmt
+
+# Build parameters
+BINARY_NAME=soundtouch-cli
+BINARY_PATH=./cmd/$(BINARY_NAME)
+BUILD_DIR=./build
+
+# Version info
+VERSION?=dev
+BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S')
+COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
+
+# Linker flags
+LDFLAGS=-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -X main.Commit=$(COMMIT)
+
+all: check build
+
+build: build-cli
+
+build-cli:
+ @echo "Building $(BINARY_NAME)..."
+ @mkdir -p $(BUILD_DIR)
+ $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME) $(BINARY_PATH)
+
+build-all: build-linux build-darwin build-windows
+
+build-linux:
+ @echo "Building for Linux..."
+ @mkdir -p $(BUILD_DIR)
+ GOOS=linux GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(BINARY_PATH)
+
+build-darwin:
+ @echo "Building for macOS..."
+ @mkdir -p $(BUILD_DIR)
+ GOOS=darwin GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(BINARY_PATH)
+ GOOS=darwin GOARCH=arm64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(BINARY_PATH)
+
+build-windows:
+ @echo "Building for Windows..."
+ @mkdir -p $(BUILD_DIR)
+ GOOS=windows GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(BINARY_PATH)
+
+test:
+ @echo "Running tests..."
+ $(GOTEST) -v ./...
+
+test-coverage:
+ @echo "Running tests with coverage..."
+ $(GOTEST) -v -coverprofile=coverage.out ./...
+ $(GOCMD) tool cover -html=coverage.out -o coverage.html
+ @echo "Coverage report generated: coverage.html"
+
+check: fmt vet test
+
+fmt:
+ @echo "Formatting code..."
+ $(GOFMT) -s -w .
+
+vet:
+ @echo "Running go vet..."
+ $(GOCMD) vet ./...
+
+lint:
+ @echo "Running golangci-lint..."
+ @which golangci-lint > /dev/null || (echo "golangci-lint not found. Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest" && exit 1)
+ golangci-lint run
+
+tidy:
+ @echo "Tidying dependencies..."
+ $(GOMOD) tidy
+
+dev: build-cli
+ @echo "Starting development CLI..."
+ $(BUILD_DIR)/$(BINARY_NAME) -help
+
+dev-discover: build-cli
+ @echo "Running device discovery..."
+ $(BUILD_DIR)/$(BINARY_NAME) -discover
+
+dev-info: build-cli
+ @echo "Getting device info (requires -host flag)..."
+ @if [ -z "$(HOST)" ]; then \
+ echo "Usage: make dev-info HOST=192.168.1.100"; \
+ exit 1; \
+ fi
+ $(BUILD_DIR)/$(BINARY_NAME) -host $(HOST) -info
+
+install: build-cli
+ @echo "Installing $(BINARY_NAME) to $(GOPATH)/bin..."
+ cp $(BUILD_DIR)/$(BINARY_NAME) $(GOPATH)/bin/
+
+clean:
+ @echo "Cleaning..."
+ $(GOCLEAN)
+ rm -rf $(BUILD_DIR)
+ rm -f coverage.out coverage.html
+
+release: clean check build-all
+ @echo "Creating release archive..."
+ @mkdir -p $(BUILD_DIR)/release
+ @for binary in $(BUILD_DIR)/$(BINARY_NAME)-*; do \
+ if [ -f "$$binary" ]; then \
+ cp "$$binary" $(BUILD_DIR)/release/; \
+ fi \
+ done
+ @echo "Release binaries created in $(BUILD_DIR)/release/"
+
+docker-build:
+ @echo "Building Docker image..."
+ docker build -t soundtouch-go:$(VERSION) .
+
+docker-dev: docker-build
+ @echo "Running development container..."
+ docker run --rm -it --network host soundtouch-go:$(VERSION)
+
+help:
+ @echo "Available targets:"
+ @echo " build - Build the CLI tool"
+ @echo " build-all - Build for all platforms"
+ @echo " test - Run tests"
+ @echo " test-coverage - Run tests with coverage report"
+ @echo " check - Run fmt, vet, and tests"
+ @echo " fmt - Format code"
+ @echo " vet - Run go vet"
+ @echo " lint - Run golangci-lint"
+ @echo " tidy - Tidy dependencies"
+ @echo " dev - Build and show CLI help"
+ @echo " dev-discover - Build and run device discovery"
+ @echo " dev-info - Build and get device info (HOST=ip required)"
+ @echo " install - Install binary to GOPATH/bin"
+ @echo " clean - Clean build artifacts"
+ @echo " release - Create release binaries"
+ @echo " docker-build - Build Docker image"
+ @echo " docker-dev - Run development container"
+ @echo " help - Show this help message"
+ @echo ""
+ @echo "Examples:"
+ @echo " make dev-discover"
+ @echo " make dev-info HOST=192.168.1.100"
+ @echo " make test"
+ @echo " make build-all"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5265606
--- /dev/null
+++ b/README.md
@@ -0,0 +1,311 @@
+# Bose SoundTouch API Client
+
+A modern Go library and CLI tool for interacting with Bose SoundTouch devices via their Web API.
+
+## Features
+
+### β
Implemented (Phase 1)
+- **HTTP Client with XML Support**: Complete client for SoundTouch Web API
+- **Device Information**: Get detailed device info via `/info` endpoint
+- **UPnP Discovery**: Automatic device discovery on local network
+- **Cross-Platform**: Works on Windows, macOS, Linux, and WASM
+- **CLI Tool**: Command-line interface for testing and basic operations
+- **Comprehensive Tests**: Unit and integration tests with real device responses
+- **Flexible Configuration**: Support for .env files and environment variables
+- **Hybrid Discovery**: Combines UPnP discovery with configured device lists
+
+### π Planned
+- Real-time WebSocket events
+- Playback control (play, pause, volume, etc.)
+- Source management (Spotify, Bluetooth, etc.)
+- Preset management
+- Web application interface
+- Multi-room zone support
+
+## Installation
+
+### Using Go
+```bash
+go install github.com/user_account/bose-soundtouch/cmd/soundtouch-cli@latest
+```
+
+### From Source
+```bash
+git clone https://github.com/user_account/bose-soundtouch.git
+cd bose-soundtouch
+make build
+```
+
+## Quick Start
+
+### Configuration
+
+Create a `.env` file in your working directory to configure preferred devices:
+
+```bash
+# Copy the example file
+cp .env.example .env
+```
+
+Example `.env` configuration:
+```bash
+# Discovery Settings
+DISCOVERY_TIMEOUT=5s
+UPNP_ENABLED=true
+
+# Preferred Devices (alternative to UPnP)
+# Format: name@host:port;name@host:port;...
+PREFERRED_DEVICES="Living Room@192.168.1.100;Kitchen@192.168.1.101;192.168.1.102:8091"
+
+# HTTP Client Settings
+HTTP_TIMEOUT=10s
+USER_AGENT="Bose-SoundTouch-Go-Client/1.0"
+```
+
+### CLI Usage
+
+#### Device Discovery
+```bash
+# Discover SoundTouch devices (combines UPnP + configured devices)
+soundtouch-cli -discover
+
+# Discover and show detailed info for all devices
+soundtouch-cli -discover-all
+```
+
+#### Device Information
+```bash
+# Get device information by IP address
+soundtouch-cli -host 192.168.1.100 -info
+
+# With custom port and timeout
+soundtouch-cli -host 192.168.1.100 -port 8090 -timeout 15s -info
+```
+
+### Go Library Usage
+
+```go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "time"
+
+ "github.com/user_account/bose-soundtouch/pkg/client"
+ "github.com/user_account/bose-soundtouch/pkg/discovery"
+)
+
+func main() {
+ // Option 1: Connect to known device
+ soundtouchClient := client.NewClientFromHost("192.168.1.100")
+
+ deviceInfo, err := soundtouchClient.GetDeviceInfo()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("Device: %s (%s)\n", deviceInfo.Name, deviceInfo.Type)
+
+ // Option 2: Discover devices automatically
+ discoveryService := discovery.NewDiscoveryService(5 * time.Second)
+ ctx := context.Background()
+
+ devices, err := discoveryService.DiscoverDevices(ctx)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for _, device := range devices {
+ fmt.Printf("Found: %s at %s:%d\n", device.Name, device.Host, device.Port)
+ }
+}
+```
+
+## Project Structure
+
+```
+βββ cmd/
+β βββ soundtouch-cli/ # CLI application
+βββ pkg/
+β βββ client/ # HTTP client with XML support
+β βββ discovery/ # UPnP SSDP device discovery
+β βββ models/ # XML data models
+βββ docs/ # Documentation
+βββ build/ # Build artifacts
+```
+
+## Development
+
+### Prerequisites
+- Go 1.25.5 or later
+- Make (optional, for convenience)
+
+### Building
+```bash
+# Build CLI tool
+make build
+
+# Build for all platforms
+make build-all
+
+# Build and run tests
+make check
+
+# Run tests with coverage
+make test-coverage
+```
+
+### Testing
+```bash
+# Run all tests
+make test
+
+# Run specific package tests
+go test -v ./pkg/client
+go test -v ./pkg/discovery
+
+# Test with real devices
+make dev-info HOST=192.168.1.100
+```
+
+### Development Commands
+```bash
+# Format code
+make fmt
+
+# Run linter (requires golangci-lint)
+make lint
+
+# Clean build artifacts
+make clean
+
+# Show help
+make help
+```
+
+## API Documentation
+
+The SoundTouch Web API uses HTTP with XML payloads. Key endpoints include:
+
+- `GET /info` - Device information
+- `GET /now_playing` - Current playback status
+- `POST /key` - Send key commands (play, pause, etc.)
+- `GET/POST /volume` - Volume control
+- `GET /sources` - Available audio sources
+- WebSocket `/` - Real-time event stream
+
+For complete API documentation, see [docs/API-Endpoints-Overview.md](docs/API-Endpoints-Overview.md).
+
+## Configuration Options
+
+The application supports configuration through `.env` files and environment variables:
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `DISCOVERY_TIMEOUT` | `5s` | Timeout for device discovery |
+| `UPNP_ENABLED` | `true` | Enable/disable UPnP discovery |
+| `PREFERRED_DEVICES` | (empty) | Semicolon-separated list of devices |
+| `HTTP_TIMEOUT` | `10s` | HTTP client timeout |
+| `CACHE_ENABLED` | `true` | Enable device caching |
+| `CACHE_TTL` | `30s` | Cache time-to-live |
+
+### Device Configuration Format
+
+The `PREFERRED_DEVICES` environment variable supports multiple formats:
+
+```bash
+# Host only (uses default port 8090)
+PREFERRED_DEVICES="192.168.1.100"
+
+# Host with port
+PREFERRED_DEVICES="192.168.1.100:8091"
+
+# Named device
+PREFERRED_DEVICES="Living Room@192.168.1.100"
+
+# Multiple devices
+PREFERRED_DEVICES="Living Room@192.168.1.100;Kitchen@192.168.1.101:8091"
+```
+
+## Supported Devices
+
+Tested with:
+- Bose SoundTouch 10
+- Bose SoundTouch 20
+
+Should work with all SoundTouch series devices that support the Web API.
+
+## Real Device Examples
+
+### SoundTouch 10 Response
+```xml
+
+ Sound Machinechen
+ SoundTouch 10
+ sm2
+ rhino
+
+
+ SCM
+ 27.0.6.46330.5043500
+
+
+
+```
+
+### SoundTouch 20 Response
+```xml
+
+ My SoundTouch Device
+ SoundTouch 20
+ scm
+ spotty
+
+
+ SCM
+ 27.0.6.46330.5043500
+
+
+ Lightswitch
+
+
+
+```
+
+## Architecture
+
+This project follows modern Go patterns:
+
+- **Clean Architecture**: Separated concerns with pkg structure
+- **Interface-Based Design**: Testable and mockable components
+- **Cross-Platform**: Supports Windows, macOS, Linux, and WASM
+- **Test-Driven**: Comprehensive unit and integration tests
+- **Real Device Integration**: Tested with actual SoundTouch hardware
+
+## Contributing
+
+1. Fork the repository
+2. Create a feature branch
+3. Add tests for new functionality
+4. Ensure all tests pass: `make check`
+5. Submit a pull request
+
+### Development Guidelines
+
+- **Tests are mandatory**: Every feature needs corresponding tests
+- **KISS principle**: Keep implementations simple and readable
+- **Small iterations**: Break large features into testable chunks
+- **Real device testing**: Validate against actual SoundTouch devices
+- **Cross-platform compatibility**: Test on multiple platforms
+
+## License
+
+This project is licensed under the MIT License - see the LICENSE file for details.
+
+## References
+
+- [Official Bose SoundTouch Web API Documentation](docs/2025.12.18%20SoundTouch%20Web%20API.pdf)
+- [Project Development Plan](docs/PLAN.md)
+- [Development Guidelines](docs/CLAUDE.md)
\ No newline at end of file
diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go
new file mode 100644
index 0000000..463dfc6
--- /dev/null
+++ b/cmd/soundtouch-cli/main.go
@@ -0,0 +1,222 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "strings"
+ "time"
+
+ "github.com/user_account/bose-soundtouch/pkg/client"
+ "github.com/user_account/bose-soundtouch/pkg/config"
+ "github.com/user_account/bose-soundtouch/pkg/discovery"
+)
+
+func main() {
+ var (
+ host = flag.String("host", "", "SoundTouch device host/IP address")
+ port = flag.Int("port", 8090, "SoundTouch device port")
+ timeout = flag.Duration("timeout", 10*time.Second, "Request timeout")
+ discover = flag.Bool("discover", false, "Discover SoundTouch devices via UPnP")
+ discoverAll = flag.Bool("discover-all", false, "Discover all SoundTouch devices and show info")
+ info = flag.Bool("info", false, "Get device information")
+ help = flag.Bool("help", false, "Show help")
+ )
+
+ flag.Parse()
+
+ if *help {
+ printHelp()
+ return
+ }
+
+ // If no specific action is requested, show help
+ if !*discover && !*discoverAll && !*info && *host == "" {
+ printHelp()
+ return
+ }
+
+ // Handle discovery
+ if *discover || *discoverAll {
+ if err := handleDiscovery(*discoverAll, *timeout); err != nil {
+ log.Fatalf("Discovery failed: %v", err)
+ }
+ return
+ }
+
+ // Handle device info
+ if *info {
+ if *host == "" {
+ log.Fatal("Host is required for info command. Use -host flag or -discover to find devices.")
+ }
+ if err := handleDeviceInfo(*host, *port, *timeout); err != nil {
+ log.Fatalf("Failed to get device info: %v", err)
+ }
+ return
+ }
+}
+
+func printHelp() {
+ fmt.Println("SoundTouch CLI - Test tool for Bose SoundTouch API")
+ fmt.Println()
+ fmt.Println("Usage:")
+ fmt.Println(" soundtouch-cli [options]")
+ fmt.Println()
+ fmt.Println("Options:")
+ fmt.Println(" -host SoundTouch device IP address")
+ fmt.Println(" -port SoundTouch device port (default: 8090)")
+ fmt.Println(" -timeout Request timeout (default: 10s)")
+ fmt.Println(" -discover Discover SoundTouch devices via UPnP")
+ fmt.Println(" -discover-all Discover devices and show detailed info")
+ fmt.Println(" -info Get device information (requires -host)")
+ fmt.Println(" -help Show this help message")
+ fmt.Println()
+ fmt.Println("Examples:")
+ fmt.Println(" soundtouch-cli -discover")
+ fmt.Println(" soundtouch-cli -discover-all")
+ fmt.Println(" soundtouch-cli -host 192.168.1.100 -info")
+ fmt.Println(" soundtouch-cli -host 192.168.1.100 -port 8090 -info")
+}
+
+func handleDiscovery(showInfo bool, timeout time.Duration) error {
+ fmt.Println("Discovering SoundTouch devices...")
+
+ // Load configuration from environment and .env file
+ cfg, err := config.LoadFromEnv()
+ if err != nil {
+ fmt.Printf("Warning: Failed to load configuration: %v\n", err)
+ cfg = config.DefaultConfig()
+ }
+
+ // Override timeout if provided via command line
+ if timeout > 0 {
+ cfg.DiscoveryTimeout = timeout
+ }
+
+ discoveryService := discovery.NewDiscoveryServiceWithConfig(cfg)
+ ctx, cancel := context.WithTimeout(context.Background(), cfg.DiscoveryTimeout+5*time.Second)
+ defer cancel()
+
+ devices, err := discoveryService.DiscoverDevices(ctx)
+ if err != nil {
+ return fmt.Errorf("discovery failed: %w", err)
+ }
+
+ if len(devices) == 0 {
+ fmt.Println("No SoundTouch devices found")
+ return nil
+ }
+
+ fmt.Printf("Found %d SoundTouch device(s):\n", len(devices))
+ for i, device := range devices {
+ fmt.Printf(" %d. %s\n", i+1, device.Name)
+ fmt.Printf(" Host: %s:%d\n", device.Host, device.Port)
+ fmt.Printf(" Location: %s\n", device.Location)
+ fmt.Printf(" Last seen: %s\n", device.LastSeen.Format("2006-01-02 15:04:05"))
+
+ // Indicate source of discovery
+ if strings.Contains(device.Location, "/info") && len(cfg.PreferredDevices) > 0 {
+ for _, prefDevice := range cfg.PreferredDevices {
+ if prefDevice.Host == device.Host && prefDevice.Port == device.Port {
+ fmt.Printf(" Source: Configuration (.env)\n")
+ break
+ }
+ }
+ } else {
+ fmt.Printf(" Source: UPnP Discovery\n")
+ }
+
+ if showInfo {
+ fmt.Printf(" Getting device info...\n")
+ if err := showDeviceInfoWithConfig(device.Host, device.Port, cfg); err != nil {
+ fmt.Printf(" Error getting info: %v\n", err)
+ }
+ }
+ fmt.Println()
+ }
+
+ return nil
+}
+
+func handleDeviceInfo(host string, port int, timeout time.Duration) error {
+ // Load configuration for HTTP settings
+ cfg, err := config.LoadFromEnv()
+ if err != nil {
+ cfg = config.DefaultConfig()
+ }
+
+ // Override timeout if provided via command line
+ if timeout > 0 {
+ cfg.HTTPTimeout = timeout
+ }
+
+ return showDeviceInfoWithConfig(host, port, cfg)
+}
+
+func showDeviceInfoWithConfig(host string, port int, cfg *config.Config) error {
+ clientConfig := client.ClientConfig{
+ Host: host,
+ Port: port,
+ Timeout: cfg.HTTPTimeout,
+ UserAgent: cfg.UserAgent,
+ }
+
+ soundtouchClient := client.NewClient(clientConfig)
+
+ fmt.Printf("Connecting to SoundTouch device at %s:%d...\n", host, port)
+
+ // Test connectivity first
+ if err := soundtouchClient.Ping(); err != nil {
+ return fmt.Errorf("failed to connect to device: %w", err)
+ }
+
+ // Get device info
+ deviceInfo, err := soundtouchClient.GetDeviceInfo()
+ if err != nil {
+ return fmt.Errorf("failed to get device info: %w", err)
+ }
+
+ // Display device information
+ fmt.Printf("Device Information:\n")
+ fmt.Printf(" Name: %s\n", deviceInfo.Name)
+ fmt.Printf(" Device ID: %s\n", deviceInfo.DeviceID)
+ fmt.Printf(" Type: %s\n", deviceInfo.Type)
+ fmt.Printf(" Module Type: %s\n", deviceInfo.ModuleType)
+ fmt.Printf(" Variant: %s (%s)\n", deviceInfo.Variant, deviceInfo.VariantMode)
+ fmt.Printf(" Country: %s\n", deviceInfo.CountryCode)
+
+ if deviceInfo.MargeAccountUUID != "" {
+ fmt.Printf(" Marge Account UUID: %s\n", deviceInfo.MargeAccountUUID)
+ }
+
+ if deviceInfo.MargeURL != "" {
+ fmt.Printf(" Marge URL: %s\n", deviceInfo.MargeURL)
+ }
+
+ if len(deviceInfo.NetworkInfo) > 0 {
+ fmt.Printf(" Network Info:\n")
+ for _, net := range deviceInfo.NetworkInfo {
+ fmt.Printf(" - Type: %s\n", net.Type)
+ fmt.Printf(" MAC Address: %s\n", net.MacAddress)
+ fmt.Printf(" IP Address: %s\n", net.IPAddress)
+ }
+ }
+
+ if len(deviceInfo.Components) > 0 {
+ fmt.Printf(" Components:\n")
+ for _, component := range deviceInfo.Components {
+ fmt.Printf(" - Category: %s\n", component.ComponentCategory)
+ if component.SoftwareVersion != "" {
+ fmt.Printf(" Software Version: %s\n", component.SoftwareVersion)
+ }
+ if component.SerialNumber != "" {
+ fmt.Printf(" Serial Number: %s\n", component.SerialNumber)
+ }
+ }
+ }
+
+ fmt.Printf(" Base URL: %s\n", soundtouchClient.BaseURL())
+
+ return nil
+}
diff --git a/docs/2025.12.18 SoundTouch Web API.pdf b/docs/2025.12.18 SoundTouch Web API.pdf
new file mode 100644
index 0000000..c05d58e
Binary files /dev/null and b/docs/2025.12.18 SoundTouch Web API.pdf differ
diff --git a/docs/API-Endpoints-Overview.md b/docs/API-Endpoints-Overview.md
new file mode 100644
index 0000000..da2e30b
--- /dev/null
+++ b/docs/API-Endpoints-Overview.md
@@ -0,0 +1,290 @@
+# Bose SoundTouch Web API - Endpoints Overview
+
+This document provides a comprehensive overview of the available API endpoints of the Bose SoundTouch Web API based on the official specification.
+
+## API Basics
+
+- **Protocol**: HTTP REST-like
+- **Data Format**: XML Request/Response
+- **Standard Port**: 8090
+- **Base URL**: `http://:8090/`
+- **Authentication**: No complex authentication required
+- **Real-time Updates**: WebSocket connection available
+
+## Device Information
+
+### GET /info
+Retrieves basic device information.
+
+**Response XML Structure:**
+```xml
+
+ Device Name
+ Device Type
+ UUID
+ ...
+
+```
+
+## Playback Control
+
+### GET /now_playing
+Retrieves information about the currently playing music.
+
+**Response XML Structure:**
+```xml
+
+
+ Track Name
+ Album Art URL
+
+
+ Artist Name
+ Album Name
+ Station Name
+ Art URL
+ PLAY_STATE
+ ...
+ ...
+
+```
+
+### POST /key
+Sends key commands to the device.
+
+**Request XML:**
+```xml
+KEY_NAME
+```
+
+**Available Keys:**
+- `PLAY`
+- `PAUSE`
+- `STOP`
+- `PREV_TRACK`
+- `NEXT_TRACK`
+- `THUMBS_UP`
+- `THUMBS_DOWN`
+- `BOOKMARK`
+- `POWER`
+- `MUTE`
+- `VOLUME_UP`
+- `VOLUME_DOWN`
+- `PRESET_1` to `PRESET_6`
+- `AUX_INPUT`
+- `SHUFFLE_OFF`
+- `SHUFFLE_ON`
+- `REPEAT_OFF`
+- `REPEAT_ONE`
+- `REPEAT_ALL`
+
+## Volume Control
+
+### GET /volume
+Retrieves the current volume.
+
+**Response XML:**
+```xml
+
+ 50
+ 50
+ false
+
+```
+
+### POST /volume
+Sets the volume.
+
+**Request XML:**
+```xml
+50
+```
+
+## Bass Settings
+
+### GET /bass
+Retrieves the current bass settings.
+
+**Response XML:**
+```xml
+
+ 0
+ 0
+
+```
+
+### POST /bass
+Sets the bass settings (-9 to +9).
+
+**Request XML:**
+```xml
+0
+```
+
+## Source Management
+
+### GET /sources
+Retrieves the available audio sources.
+
+**Response XML:**
+```xml
+
+
+ Spotify
+
+
+ Bluetooth
+
+
+
+```
+
+**Typical Sources:**
+- `SPOTIFY`
+- `AMAZON`
+- `PANDORA`
+- `IHEARTRADIO`
+- `TUNEIN`
+- `BLUETOOTH`
+- `AUX`
+- `STORED_MUSIC`
+
+### POST /select
+Selects an audio source.
+
+**Request XML:**
+```xml
+
+ Spotify
+
+```
+
+## Preset Management
+
+### GET /presets
+Retrieves the configured presets.
+
+**Response XML:**
+```xml
+
+
+
+ Preset Name
+ Art URL
+
+
+
+
+```
+
+### POST /presets
+Creates or updates a preset.
+
+**Request XML:**
+```xml
+
+
+ Preset Name
+
+
+```
+
+## Advanced Features
+
+### GET /getZone
+Retrieves multiroom zone information.
+
+### POST /setZone
+Configures multiroom zones.
+
+### GET /balance
+Retrieves balance settings (stereo devices).
+
+### POST /balance
+Sets balance settings.
+
+### GET /clockTime
+Retrieves the device time.
+
+### POST /clockTime
+Sets the device time.
+
+### GET /clockDisplay
+Retrieves clock display settings.
+
+### POST /clockDisplay
+Configures the clock display.
+
+## WebSocket Connection
+
+### WebSocket /
+Establishes a persistent connection for live updates.
+
+**Event Types:**
+- `nowPlayingUpdated`
+- `volumeUpdated`
+- `connectionStateUpdated`
+- `presetUpdated`
+
+## Network and System
+
+### GET /networkInfo
+Retrieves network information.
+
+### GET /capabilities
+Retrieves device capabilities.
+
+### POST /reboot
+Restarts the device.
+
+## Error Handling
+
+The API uses standard HTTP status codes:
+- `200 OK` - Successful request
+- `400 Bad Request` - Invalid request
+- `404 Not Found` - Endpoint or resource not found
+- `500 Internal Server Error` - Internal device error
+
+## Example Implementation
+
+```go
+// Example for a GET request
+func GetNowPlaying(deviceIP string) (*NowPlaying, error) {
+ url := fmt.Sprintf("http://%s:8090/now_playing", deviceIP)
+ resp, err := http.Get(url)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var nowPlaying NowPlaying
+ err = xml.NewDecoder(resp.Body).Decode(&nowPlaying)
+ return &nowPlaying, err
+}
+
+// Example for a POST request
+func SendKey(deviceIP string, key string) error {
+ url := fmt.Sprintf("http://%s:8090/key", deviceIP)
+ xmlData := fmt.Sprintf(`%s`, key)
+
+ resp, err := http.Post(url, "application/xml", strings.NewReader(xmlData))
+ if err != nil {
+ return err
+ }
+ resp.Body.Close()
+ return nil
+}
+```
+
+## Notes
+
+1. **XML Namespace**: Most responses use no explicit XML namespace
+2. **Encoding**: UTF-8 is used for all XML documents
+3. **Timeouts**: Recommended timeout for HTTP requests: 10 seconds
+4. **Rate Limiting**: No explicit limits documented, but moderate usage recommended
+5. **Device Discovery**: Devices can be found via UPnP on the local network
+
+## Reference
+
+Based on the official Bose SoundTouch Web API documentation:
+https://assets.bosecreative.com/m/496577402d128874/original/SoundTouch-Web-API.pdf
\ No newline at end of file
diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md
new file mode 100644
index 0000000..57ea099
--- /dev/null
+++ b/docs/CLAUDE.md
@@ -0,0 +1,69 @@
+# CLAUDE.md - Development Guidelines for Bose SoundTouch Project
+
+## Documentation Overview
+
+This document contains important development guidelines for working on the Bose SoundTouch project. Please also read the following documentation:
+
+- **[PLAN.md](PLAN.md)** - Project planning and roadmap
+- **[PROJECT-PATTERNS.md](PROJECT-PATTERNS.md)** - Project structure and design patterns
+- **[API-Endpoints-Overview.md](API-Endpoints-Overview.md)** - API endpoints overview
+- **[SoundTouch Web API.pdf](2025.12.18%20SoundTouch%20Web%20API.pdf)** - Official API documentation
+
+## Development Guidelines
+
+### 1. Tests are Mandatory
+
+- **Implementation always with tests**: Every new functionality must be developed with corresponding tests
+- **Unit tests preferred**: Where possible, unit tests should be written
+- **Integration tests as alternative**: If unit tests are not practical, implement integration tests via mock servers
+- **Sample data from live system**: Request/response data can be taken from a real SoundTouch system as examples
+- **Respect privacy**: All personal data must be anonymized before use in tests
+
+### 2. Cross-Platform Compatibility
+
+The project must work on the following platforms:
+- **Windows**
+- **macOS**
+- **Linux**
+- **WASM** (WebAssembly)
+
+Platform-specific implementations are only allowed in justified exceptional cases.
+
+### 3. KISS Principle (Keep It Simple, Stupid)
+
+- Simplicity has top priority
+- Complex solutions only when absolutely necessary
+- Code should be self-explanatory and well readable
+- Avoid over-engineering
+
+### 4. Small Steps and Communication
+
+- **Small, iterative steps**: Break large features into smaller, testable units
+- **Don't hallucinate**: Don't make assumptions about unclear requirements
+- **Ask instead of guess**: Always ask when unclear instead of speculating
+- **Transparency**: Openly communicate uncertainties and limitations
+
+### 5. Use Current Libraries
+
+- Use current and well-maintained libraries where possible
+- Regularly update outdated dependencies
+- Apply security updates promptly
+- Ensure compatibility with Go modules
+
+### 6. Web-Specific Implementation
+
+For web components:
+- **Prefer plain HTML/JS/CSS**: Avoid heavy frameworks where possible
+- Use modern web standards (ES6+, CSS Grid/Flexbox)
+- Apply progressive enhancement
+- Consider accessibility (a11y)
+- Implement responsive design
+
+## Additional Notes
+
+- **Language: English** for code, commits, labels, and text in code
+- Code comments in English
+- **Documentation**: Completely in English for international accessibility
+- Conduct regular code reviews
+- Consider performance from the beginning
+
diff --git a/docs/PLAN.md b/docs/PLAN.md
new file mode 100644
index 0000000..0557838
--- /dev/null
+++ b/docs/PLAN.md
@@ -0,0 +1,680 @@
+# Bose SoundTouch API Client - Golang Implementation Plan
+
+## Overview
+
+This document describes the planning for a Golang-based API client for the Bose SoundTouch Web API. The client follows modern Go patterns and supports both native Go library and WASM integration with embedded web UI.
+
+**New insights from pattern analysis:**
+- Single binary deployment with embedded assets
+- Multi-target build system (Native + WASM)
+- CORS proxy pattern for browser integration
+- Robust XML API client patterns
+- Production-ready configuration management
+
+## API Fundamentals
+
+### Basic Information
+- **Protocol**: HTTP REST-like
+- **Data format**: XML Request/Response
+- **Port**: 8090 (default)
+- **Authentication**: No complex authentication required
+- **Real-time updates**: WebSocket connection available
+- **Device discovery**: UPnP discovery possible
+
+### Core API Endpoints
+- `GET /info` - Device information
+- `GET /now_playing` - Currently playing music
+- `POST /key` - Send key commands (PLAY, PAUSE, etc.)
+- `GET/POST /volume` - Control volume
+- `GET/POST /bass` - Bass settings
+- `GET/POST /sources` - Available sources
+- `POST /select` - Select source
+- `GET/POST /presets` - Manage presets (1-6)
+- `WebSocket /` - Live updates for events
+
+## Architecture Based on Modern Go Patterns
+
+### Final Project Structure
+```
+github.com/user_account/bose-soundtouch/
+βββ cmd/
+β βββ cli/ # CLI Tool (Main Application)
+β β βββ main.go
+β βββ webapp/ # Web Application with embedded Assets
+β β βββ main.go
+β β βββ web/ # Embedded HTML/CSS/JS
+β β βββ index.html
+β β βββ app.js
+β β βββ style.css
+β βββ wasm/ # WASM Entry Point
+β βββ main.go
+βββ pkg/ # Public API (external usage)
+β βββ client/ # HTTP Client with XML Support
+β βββ discovery/ # UPnP Device Discovery
+β βββ models/ # Type-safe XML Data Models
+β βββ websocket/ # Event Streaming Client
+β βββ wasm/ # WASM JavaScript Bridge
+β βββ config/ # Configuration Management
+βββ internal/ # Private Implementation Details
+β βββ xml/ # XML Parsing Utilities
+β βββ http/ # HTTP Utilities & Middleware
+β βββ testing/ # Mock Client & Test Utilities
+βββ web/ # Frontend Development Assets
+β βββ src/ # Source files
+β βββ dist/ # Build output β cmd/webapp/web/
+βββ examples/ # Usage Examples & Demos
+βββ test/ # Integration Tests & Docker
+βββ Makefile # Comprehensive Build System
+βββ .env.example # Configuration Template
+βββ .air-webapp.toml # Hot Reload Config
+βββ .air-wasm.toml # WASM Development Config
+βββ docker-compose.yml # Development Environment
+βββ PROJECT-PATTERNS.md # Pattern Documentation
+βββ API-Endpoints-Overview.md # API Reference
+βββ go.mod
+βββ README.md
+```
+
+### Core Components (Updated)
+
+#### 1. HTTP Client with XML Support (`pkg/client`)
+```go
+type Client struct {
+ baseURL string
+ httpClient *http.Client
+ timeout time.Duration
+ userAgent string
+}
+
+type ClientConfig struct {
+ Host string
+ Port int
+ Timeout time.Duration
+ UserAgent string
+}
+
+// Core API methods
+func NewClient(config ClientConfig) *Client
+func (c *Client) GetDeviceInfo() (*models.DeviceInfo, error)
+func (c *Client) GetNowPlaying() (*models.NowPlaying, error)
+func (c *Client) SetVolume(volume int) error
+func (c *Client) GetVolume() (*models.Volume, error)
+func (c *Client) SendKey(key models.Key) error
+func (c *Client) GetSources() (*models.Sources, error)
+func (c *Client) SelectSource(source models.ContentItem) error
+func (c *Client) GetPresets() (*models.Presets, error)
+func (c *Client) SetPreset(id int, content models.ContentItem) error
+
+// HTTP utilities with XML handling
+func (c *Client) get(endpoint string, result interface{}) error
+func (c *Client) post(endpoint string, data interface{}, result interface{}) error
+```
+
+#### 2. UPnP Device Discovery (`pkg/discovery`)
+```go
+type DiscoveryService struct {
+ timeout time.Duration
+ cache map[string]*Device
+ cacheTTL time.Duration
+ mutex sync.RWMutex
+}
+
+type Device struct {
+ Name string `json:"name"`
+ Host string `json:"host"`
+ Port int `json:"port"`
+ ModelID string `json:"modelId"`
+ SerialNo string `json:"serialNo"`
+ Location string `json:"location"`
+ LastSeen time.Time `json:"lastSeen"`
+}
+
+func NewDiscoveryService(timeout time.Duration) *DiscoveryService
+func (d *DiscoveryService) DiscoverDevices() ([]Device, error)
+func (d *DiscoveryService) DiscoverDevice(name string) (*Device, error)
+func (d *DiscoveryService) GetCachedDevices() []Device
+func (d *DiscoveryService) ClearCache()
+
+// SSDP/UPnP implementation
+func (d *DiscoveryService) sendMSearch() error
+func (d *DiscoveryService) parseResponse(response string) (*Device, error)
+```
+
+#### 3. Typsichere XML Models (`pkg/models`)
+```go
+// Base XML response with error handling
+type XMLResponse struct {
+ XMLName xml.Name `xml:",innerxml"`
+ Error *APIError `xml:"error,omitempty"`
+}
+
+type APIError struct {
+ Code string `xml:"code,attr"`
+ Message string `xml:",innerxml"`
+}
+
+// Device Info
+type DeviceInfo struct {
+ XMLResponse
+ XMLName xml.Name `xml:"info"`
+ DeviceID string `xml:"deviceID,attr"`
+ Name string `xml:"name"`
+ Type string `xml:"type"`
+ Components []string `xml:"components>component"`
+ // ... additional fields
+}
+
+// Now Playing with complete structure
+type NowPlaying struct {
+ XMLResponse
+ XMLName xml.Name `xml:"nowPlaying"`
+ DeviceID string `xml:"deviceID,attr"`
+ Source string `xml:"source,attr"`
+ Content ContentItem `xml:"ContentItem"`
+ Track string `xml:"track"`
+ Artist string `xml:"artist"`
+ Album string `xml:"album"`
+ Art Art `xml:"art"`
+ PlayStatus PlayStatus `xml:"playStatus"`
+ Position Position `xml:"position,omitempty"`
+}
+
+// Enum types with validation
+type PlayStatus string
+const (
+ PlayStatusPlaying PlayStatus = "PLAY_STATE"
+ PlayStatusPaused PlayStatus = "PAUSE_STATE"
+ PlayStatusStopped PlayStatus = "STOP_STATE"
+)
+
+type Key string
+const (
+ KeyPlay Key = "PLAY"
+ KeyPause Key = "PAUSE"
+ KeyStop Key = "STOP"
+ KeyPrevTrack Key = "PREV_TRACK"
+ KeyNextTrack Key = "NEXT_TRACK"
+ KeyVolumeUp Key = "VOLUME_UP"
+ KeyVolumeDown Key = "VOLUME_DOWN"
+ KeyMute Key = "MUTE"
+ KeyPower Key = "POWER"
+ KeyPreset1 Key = "PRESET_1"
+ KeyPreset2 Key = "PRESET_2"
+ KeyPreset3 Key = "PRESET_3"
+ KeyPreset4 Key = "PRESET_4"
+ KeyPreset5 Key = "PRESET_5"
+ KeyPreset6 Key = "PRESET_6"
+)
+```
+
+#### 4. WebSocket Event Client (`pkg/websocket`)
+```go
+type EventClient struct {
+ client *client.Client
+ conn *websocket.Conn
+ handlers map[string]EventHandler
+ stopChan chan bool
+ reconnect bool
+ backoff time.Duration
+ maxBackoff time.Duration
+}
+
+type EventHandler func(event Event)
+
+type Event struct {
+ Type string `xml:"type,attr"`
+ DeviceID string `xml:"deviceID,attr"`
+ Data interface{} `xml:",innerxml"`
+ Timestamp time.Time `json:"timestamp"`
+}
+
+func NewEventClient(client *client.Client) *EventClient
+func (e *EventClient) Subscribe(eventType string, handler EventHandler)
+func (e *EventClient) Unsubscribe(eventType string)
+func (e *EventClient) Start() error
+func (e *EventClient) Stop() error
+func (e *EventClient) IsConnected() bool
+
+// Event types
+const (
+ EventNowPlayingUpdated = "nowPlayingUpdated"
+ EventVolumeUpdated = "volumeUpdated"
+ EventConnectionState = "connectionStateUpdated"
+ EventPresetUpdated = "presetUpdated"
+)
+```
+
+#### 5. WASM JavaScript Bridge (`pkg/wasm`)
+```go
+//go:build wasm
+// +build wasm
+
+import "syscall/js"
+
+// Global WASM API registration
+func RegisterWASMFunctions()
+
+// Device Discovery (via proxy)
+func wasmDiscoverDevices(this js.Value, args []js.Value) interface{}
+
+// Client Management
+func wasmCreateClient(this js.Value, args []js.Value) interface{}
+func wasmGetNowPlaying(this js.Value, args []js.Value) interface{}
+func wasmSendKey(this js.Value, args []js.Value) interface{}
+func wasmSetVolume(this js.Value, args []js.Value) interface{}
+func wasmGetSources(this js.Value, args []js.Value) interface{}
+
+// Event Streaming
+func wasmStartEventStream(this js.Value, args []js.Value) interface{}
+func wasmStopEventStream(this js.Value, args []js.Value) interface{}
+```
+
+#### 6. Configuration Management (`pkg/config`)
+```go
+type Config struct {
+ // Server configuration
+ WebPort int `env:"WEB_PORT" default:"8080"`
+ APITimeout time.Duration `env:"API_TIMEOUT" default:"10s"`
+
+ // Discovery configuration
+ DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"5s"`
+ CacheDevices bool `env:"CACHE_DEVICES" default:"true"`
+ CacheTTL time.Duration `env:"CACHE_TTL" default:"5m"`
+
+ // CORS configuration (for web proxy)
+ CORSOrigins []string `env:"CORS_ORIGINS" default:"*"`
+
+ // Logging
+ LogLevel string `env:"LOG_LEVEL" default:"info"`
+ LogFormat string `env:"LOG_FORMAT" default:"json"`
+
+ // Development
+ DevMode bool `env:"DEV_MODE" default:"false"`
+}
+
+func Load() Config
+func LoadFromFile(filename string) (Config, error)
+func (c Config) Validate() error
+```
+
+## Implementation Roadmap (Updated)
+
+### Phase 1: Foundation & Core API β (Priority)
+- [x] Go module setup with modern dependencies
+- [ ] **Implement HTTP Client with XML Support**
+ - Basic client structure
+ - GET/POST methods with XML marshaling
+ - Error handling for HTTP + XML
+ - Timeout and retry logic
+- [ ] **Define core XML models**
+ - DeviceInfo, NowPlaying, Volume, Sources
+ - Custom XML unmarshaling for enums
+ - Validation and defaults
+- [ ] **Basic CLI tool for testing**
+ - Test device connection
+ - Basic operations (Info, Volume, Keys)
+- [ ] **Unit tests with mocks**
+ - HTTP client tests
+ - XML parsing tests
+ - Mock SoundTouch server for tests
+
+### Phase 2: Device Discovery & Management π
+- [ ] **Implement UPnP SSDP Discovery**
+ - M-SEARCH implementation
+ - Response parsing
+ - Device caching with TTL
+- [ ] **CLI Device Selection**
+ - Automatic discovery
+ - Interactive device selection
+ - Saved device configuration
+- [ ] **Integration Tests**
+ - Tests against real SoundTouch devices
+ - Docker-based mock devices
+- [ ] **Error Handling & Logging**
+ - Structured logging
+ - Graceful error handling
+ - Network error recovery
+
+### Phase 3: WebSocket Real-time Events π‘
+- [ ] **Implement WebSocket Client**
+ - Connection Management
+ - Event parsing and routing
+ - Reconnection with exponential backoff
+- [ ] **Event Handler System**
+ - Typed event structs
+ - Handler Registration
+ - Event Filtering
+- [ ] **CLI Real-time Monitoring**
+ - Live Now-Playing Updates
+ - Volume Change Monitoring
+ - Connection Status Display
+- [ ] **Event Storage & History**
+ - Event logging for debugging
+ - Historical Event Queries
+
+### Phase 4: Web Application & CORS Proxy π
+- [ ] **Create Embedded Web UI**
+ - HTML/CSS/JS for SoundTouch control
+ - Responsive design for mobile
+ - Real-time Updates via WebSocket
+- [ ] **CORS-Proxy Server**
+ - HTTP proxy to local SoundTouch devices
+ - WebSocket proxy for events
+ - CORS Header Management
+- [ ] **Single Binary with Embedded Assets**
+ - go:embed for web assets
+ - Static File Serving
+ - SPA Routing Support
+- [ ] **Web-UI Features**
+ - Device Discovery & Selection
+ - Now playing display with album art
+ - Volume & Bass Controls
+ - Source Selection
+ - Preset Management
+
+### Phase 5: WASM Browser Integration π§©
+- [ ] **WASM Build Configuration**
+ - Build tags and conditional compilation
+ - WASM-specific HTTP client (via proxy)
+ - JavaScript Promise Integration
+- [ ] **WASM JavaScript Bridge**
+ - Go function export to JavaScript
+ - Asynchronous API calls
+ - Error handling via promise rejection
+- [ ] **Browser Demo Application**
+ - Pure Frontend SoundTouch Control
+ - Local Network Device Discovery (via Proxy)
+ - Real-time Event Updates
+- [ ] **Cross-Origin Solutions**
+ - Local proxy server for development
+ - Browser Extension Support
+ - Documentation for CORS issues
+
+### Phase 6: Production Features & Polish π
+- [ ] **Advanced Configuration**
+ - Environment-based Config
+ - Configuration File Support
+ - Runtime Configuration Updates
+- [ ] **Multi-Device Support**
+ - Multiple Device Connections
+ - Device Groups/Zones
+ - Synchronized Operations
+- [ ] **Preset & Source Management**
+ - Preset Backup/Restore
+ - Custom Source Integration
+ - Playlist Management
+- [ ] **Performance Optimizations**
+ - Connection Pooling
+ - Request Caching
+ - Lazy Loading
+- [ ] **Documentation & Examples**
+ - Comprehensive API Documentation
+ - Usage examples for all use cases
+ - Best Practices Guide
+
+## Build System Based on Modern Patterns
+
+### Makefile with Multi-Target Support
+```makefile
+BINARY_NAME=soundtouch
+VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
+BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S')
+GO_VERSION=$(shell go version | cut -d ' ' -f 3)
+
+LDFLAGS=-ldflags "-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -X main.GoVersion=$(GO_VERSION)"
+BUILD_FLAGS=-trimpath $(LDFLAGS)
+
+# Development builds
+build:
+ go build $(BUILD_FLAGS) -o $(BINARY_NAME) ./cmd/cli
+
+build-webapp:
+ go build $(BUILD_FLAGS) -o $(BINARY_NAME)-webapp ./cmd/webapp
+
+# WASM build
+build-wasm:
+ GOOS=js GOARCH=wasm go build $(BUILD_FLAGS) -o web/soundtouch.wasm ./cmd/wasm
+ cp "$(shell go env GOROOT)/misc/wasm/wasm_exec.js" web/
+
+# Cross-platform builds
+build-all: build-linux build-darwin build-windows
+
+# Development with hot reload
+dev-cli:
+ air -c .air-cli.toml
+
+dev-webapp:
+ air -c .air-webapp.toml
+
+dev-wasm:
+ air -c .air-wasm.toml
+
+# Testing
+test:
+ go test -v -race ./...
+
+test-coverage:
+ go test -v -coverprofile=coverage.out ./...
+ go tool cover -html=coverage.out -o coverage.html
+
+# Quality checks
+check: fmt vet lint test
+
+# Docker development environment
+docker-dev:
+ docker-compose up --build
+
+# Release packaging
+release: build-all
+ mkdir -p dist
+ tar -czf dist/$(BINARY_NAME)-$(VERSION)-linux-amd64.tar.gz $(BINARY_NAME)-linux-amd64
+ tar -czf dist/$(BINARY_NAME)-$(VERSION)-darwin-amd64.tar.gz $(BINARY_NAME)-darwin-amd64
+ zip dist/$(BINARY_NAME)-$(VERSION)-windows-amd64.zip $(BINARY_NAME)-windows-amd64.exe
+```
+
+## Technical Solution Approaches (Updated)
+
+### WASM Browser Integration
+1. **CORS Proxy Pattern**: Go web app as proxy between browser and SoundTouch devices
+2. **Local Development Server**: CORS headers for local development
+3. **WebSocket Proxy**: Real-time events via secure WebSocket connection
+4. **Graceful Degradation**: Functionality depending on browser environment
+
+### XML API Robustness
+1. **Type-Safe Models**: Strict Go structs with validation
+2. **Custom Unmarshaling**: Enum validation and error recovery
+3. **Timeout Handling**: Robust network calls with retry logic
+4. **Connection Pooling**: Efficient HTTP client reuse
+
+### Multi-Platform Deployment
+1. **Single Binary**: Embedded assets eliminate external dependencies
+2. **Cross-Compilation**: Native binaries for all platforms
+3. **Docker Support**: Containerized development and deployment
+4. **Progressive Enhancement**: CLI β WebApp β WASM depending on requirements
+
+## Example Usage (Updated)
+
+### Native Go Library
+```go
+package main
+
+import (
+ "fmt"
+ "log"
+ "time"
+
+ "github.com/user_account/bose-soundtouch/pkg/client"
+ "github.com/user_account/bose-soundtouch/pkg/discovery"
+ "github.com/user_account/bose-soundtouch/pkg/models"
+)
+
+func main() {
+ // Discover devices
+ discoveryService := discovery.NewDiscoveryService(5 * time.Second)
+ devices, err := discoveryService.DiscoverDevices()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ if len(devices) == 0 {
+ log.Fatal("No SoundTouch devices found")
+ }
+
+ // Create client for first device
+ client := client.NewClient(client.ClientConfig{
+ Host: devices[0].Host,
+ Port: 8090,
+ Timeout: 10 * time.Second,
+ })
+
+ // Get device info
+ info, err := client.GetDeviceInfo()
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("Connected to: %s\n", info.Name)
+
+ // Get current playback
+ nowPlaying, err := client.GetNowPlaying()
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ if nowPlaying.PlayStatus == models.PlayStatusPlaying {
+ fmt.Printf("Playing: %s - %s (%s)\n",
+ nowPlaying.Artist, nowPlaying.Track, nowPlaying.Album)
+ }
+
+ // Control playback
+ if nowPlaying.PlayStatus == models.PlayStatusPlaying {
+ client.SendKey(models.KeyPause)
+ fmt.Println("Paused playback")
+ } else {
+ client.SendKey(models.KeyPlay)
+ fmt.Println("Started playback")
+ }
+}
+```
+
+### CLI Usage
+```bash
+# Discover devices
+soundtouch discover
+
+# Device operations
+soundtouch --device 192.168.1.100 info
+soundtouch --device 192.168.1.100 play
+soundtouch --device 192.168.1.100 volume 50
+soundtouch --device 192.168.1.100 preset 1
+
+# Interactive mode
+soundtouch interactive
+
+# Web interface
+soundtouch-webapp --port 8080
+```
+
+### JavaScript/WASM Usage
+```javascript
+// Load WASM module
+await loadWASM('/soundtouch.wasm');
+
+// Discover devices (via proxy)
+const devices = await boseAPI.discoverDevices();
+console.log('Found devices:', devices);
+
+// Create client
+const client = boseAPI.createClient(devices[0].host, 8090);
+
+// Get now playing
+const nowPlaying = await client.getNowPlaying();
+console.log(`Playing: ${nowPlaying.artist} - ${nowPlaying.track}`);
+
+// Control playback
+await client.sendKey('PAUSE');
+
+// Volume control
+await client.setVolume(75);
+
+// Real-time events
+client.startEventStream((event) => {
+ if (event.type === 'nowPlayingUpdated') {
+ updateUI(event.data);
+ }
+});
+```
+
+## Testing Strategy (Enhanced)
+
+### Unit Tests
+- **Mock HTTP Client**: Simulierte SoundTouch-Responses
+- **XML Parsing Tests**: Robustness fΓΌr verschiedene Response-Formate
+- **Model Validation**: Enum-Validation und Edge-Cases
+- **Error Handling**: Network Failures und API Errors
+
+### Integration Tests
+- **Real Device Tests**: Gegen echte SoundTouch-Hardware
+- **Docker Mock Server**: Simulierte SoundTouch-API fΓΌr CI/CD
+- **Discovery Tests**: UPnP SSDP in verschiedenen Netzwerk-Szenarien
+- **WebSocket Tests**: Event-Streaming und Reconnection
+
+### E2E Tests
+- **CLI Tests**: Command-Line Interface Validation
+- **Web Interface Tests**: Browser-basierte Tests mit Headless Chrome
+- **WASM Tests**: Browser WASM Module Loading und Execution
+- **Cross-Platform Tests**: Builds auf Linux/macOS/Windows
+
+## Deployment Strategies
+
+### Single Binary Distribution
+```bash
+# CLI Tool
+./soundtouch-linux-amd64 discover
+./soundtouch-linux-amd64 --device IP play
+
+# Web Application (embedded assets)
+./soundtouch-webapp-linux-amd64 --port 8080
+
+# Docker
+docker run -p 8080:8080 soundtouch-webapp
+```
+
+### Development Environment
+```bash
+# Local development with hot reload
+make dev-webapp # Web app development
+make dev-wasm # WASM development
+make dev-cli # CLI development
+
+# Full development environment
+docker-compose up # Mock devices + web app
+```
+
+## Success Criteria
+
+### Phase 1-2 (Foundation)
+- β
Stable HTTP API connection to SoundTouch devices
+- β
Complete XML model coverage for core API
+- β
Automatic device discovery via UPnP
+- β
Functional CLI tool for all basic operations
+
+### Phase 3-4 (Real-time & Web)
+- β
WebSocket event streaming with reconnection
+- β
Web UI with responsive design
+- β
Single binary deployment with embedded assets
+- β
CORS proxy for browser integration
+
+### Phase 5-6 (Advanced)
+- β
WASM integration with JavaScript bridge
+- β
Multi-Device Support
+- β
Production-ready Configuration Management
+- β
Comprehensive documentation and examples
+
+## Resources & References
+
+- [Bose SoundTouch Web API Documentation](https://assets.bosecreative.com/m/496577402d128874/original/SoundTouch-Web-API.pdf)
+- [Go WebAssembly](https://github.com/golang/go/wiki/WebAssembly)
+- [UPnP Device Architecture](http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.0.pdf)
+- [Go Embed Directive](https://pkg.go.dev/embed)
+- [Gorilla WebSocket](https://github.com/gorilla/websocket)
+- [PROJECT-PATTERNS.md](./PROJECT-PATTERNS.md) - Detailed pattern documentation
\ No newline at end of file
diff --git a/docs/PROJECT-PATTERNS.md b/docs/PROJECT-PATTERNS.md
new file mode 100644
index 0000000..88d1013
--- /dev/null
+++ b/docs/PROJECT-PATTERNS.md
@@ -0,0 +1,805 @@
+# Project Structure Patterns: Bose SoundTouch API Client
+## Summary for Reuse in API Client Projects
+
+This document describes the most important patterns for the Bose SoundTouch API client, especially for XML-based API clients with Web UI, CLI tool, and WASM support.
+
+## 1. Multi-Target Build Pattern
+
+### The Core Pattern for Different Deployment Targets
+
+```go
+//go:build !wasm
+// +build !wasm
+
+// Native Go implementation
+
+//go:build wasm
+// +build wasm
+
+// WASM-specific implementation
+```
+
+**Key Aspects:**
+- **Native Builds**: Full API functionality for CLI and server
+- **WASM Builds**: Browser-compatible subset functionality
+- **Cross-Platform**: Linux, macOS, Windows support
+- **Embedded Assets**: Web UI directly embedded in binary
+
+### Build System for Multi-Target
+
+```makefile
+# Native builds
+build:
+ go build -o $(BINARY_NAME) ./cmd/cli
+
+# WASM build
+build-wasm:
+ GOOS=js GOARCH=wasm go build -o web/soundtouch.wasm ./cmd/wasm
+
+# Web application with embedded assets
+build-webapp:
+ go build -o $(BINARY_NAME)-webapp ./cmd/webapp
+```
+
+## 2. XML-API Client Pattern
+
+### HTTP Client with XML Parsing
+
+```go
+type Client struct {
+ baseURL string
+ httpClient *http.Client
+ timeout time.Duration
+}
+
+func NewClient(host string, port int) *Client {
+ return &Client{
+ baseURL: fmt.Sprintf("http://%s:%d", host, port),
+ httpClient: &http.Client{Timeout: 10 * time.Second},
+ timeout: 10 * time.Second,
+ }
+}
+
+func (c *Client) GetNowPlaying() (*models.NowPlaying, error) {
+ resp, err := c.httpClient.Get(c.baseURL + "/now_playing")
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var nowPlaying models.NowPlaying
+ err = xml.NewDecoder(resp.Body).Decode(&nowPlaying)
+ return &nowPlaying, err
+}
+```
+
+**XML Request Pattern:**
+```go
+func (c *Client) SendKey(key models.Key) error {
+ keyXML := fmt.Sprintf(`%s`, key)
+
+ resp, err := c.httpClient.Post(
+ c.baseURL+"/key",
+ "application/xml",
+ strings.NewReader(keyXML),
+ )
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ return nil
+}
+```
+
+## 3. Device Discovery Pattern
+
+### UPnP Discovery for Local Devices
+
+```go
+type DiscoveryService struct {
+ timeout time.Duration
+ cache map[string]*Device
+ mutex sync.RWMutex
+}
+
+type Device struct {
+ Name string `json:"name"`
+ Host string `json:"host"`
+ Port int `json:"port"`
+ ModelID string `json:"modelId"`
+ SerialNo string `json:"serialNo"`
+}
+
+func (d *DiscoveryService) DiscoverDevices() ([]Device, error) {
+ // UPnP SSDP Discovery implementation
+ conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
+ if err != nil {
+ return nil, err
+ }
+ defer conn.Close()
+
+ // Send M-SEARCH request
+ searchRequest := "M-SEARCH * HTTP/1.1\r\n" +
+ "HOST: 239.255.255.250:1900\r\n" +
+ "MAN: \"ssdp:discover\"\r\n" +
+ "ST: urn:schemas-upnp-org:device:MediaRenderer:1\r\n" +
+ "MX: 3\r\n\r\n"
+
+ // Implementation details...
+ return devices, nil
+}
+```
+
+## 4. WebSocket Event Stream Pattern
+
+### Real-time Updates for Audio Devices
+
+```go
+type EventClient struct {
+ client *Client
+ conn *websocket.Conn
+ handlers map[string]EventHandler
+ stopChan chan bool
+ reconnect bool
+}
+
+type EventHandler func(event Event)
+
+type Event struct {
+ Type string `xml:"type,attr"`
+ DeviceID string `xml:"deviceID,attr"`
+ Data interface{} `xml:",innerxml"`
+ Timestamp time.Time
+}
+
+func (e *EventClient) Subscribe(eventType string, handler EventHandler) {
+ e.handlers[eventType] = handler
+}
+
+func (e *EventClient) Start() error {
+ u := url.URL{Scheme: "ws", Host: e.client.host + ":8090", Path: "/"}
+
+ conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
+ if err != nil {
+ return err
+ }
+ e.conn = conn
+
+ go e.eventLoop()
+ return nil
+}
+
+func (e *EventClient) eventLoop() {
+ for {
+ select {
+ case <-e.stopChan:
+ return
+ default:
+ var event Event
+ err := e.conn.ReadJSON(&event)
+ if err != nil {
+ if e.reconnect {
+ e.reconnectWithBackoff()
+ continue
+ }
+ return
+ }
+
+ if handler, exists := e.handlers[event.Type]; exists {
+ go handler(event)
+ }
+ }
+ }
+}
+```
+
+## 5. WASM JavaScript Bridge Pattern
+
+### Go-to-JavaScript Function Mapping
+
+```go
+//go:build wasm
+// +build wasm
+
+import (
+ "syscall/js"
+ "encoding/json"
+)
+
+func RegisterWASMFunctions() {
+ js.Global().Set("boseAPI", js.ValueOf(map[string]interface{}{
+ "discoverDevices": js.FuncOf(wasmDiscoverDevices),
+ "createClient": js.FuncOf(wasmCreateClient),
+ "getNowPlaying": js.FuncOf(wasmGetNowPlaying),
+ "sendKey": js.FuncOf(wasmSendKey),
+ "setVolume": js.FuncOf(wasmSetVolume),
+ }))
+}
+
+func wasmDiscoverDevices(this js.Value, args []js.Value) interface{} {
+ handler := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
+ go func() {
+ devices, err := discovery.NewDiscoveryService(5*time.Second).DiscoverDevices()
+
+ result := make(map[string]interface{})
+ if err != nil {
+ result["error"] = err.Error()
+ } else {
+ devicesJSON, _ := json.Marshal(devices)
+ result["devices"] = string(devicesJSON)
+ }
+
+ // Call JavaScript callback
+ args[0].Invoke(js.ValueOf(result))
+ }()
+ return nil
+ })
+
+ return handler
+}
+```
+
+### JavaScript Integration
+
+```javascript
+// Browser usage
+async function discoverDevices() {
+ return new Promise((resolve, reject) => {
+ window.boseAPI.discoverDevices((result) => {
+ if (result.error) {
+ reject(new Error(result.error));
+ } else {
+ resolve(JSON.parse(result.devices));
+ }
+ });
+ });
+}
+
+// Usage example
+const devices = await discoverDevices();
+const client = boseAPI.createClient(devices[0].host, 8090);
+const nowPlaying = await client.getNowPlaying();
+```
+
+## 6. CLI Tool Pattern with Device Selection
+
+### Interactive Device Selection
+
+```go
+// cmd/cli/main.go
+func main() {
+ app := &cli.App{
+ Name: "soundtouch",
+ Usage: "Bose SoundTouch API Client",
+ Commands: []*cli.Command{
+ {
+ Name: "discover",
+ Usage: "Discover SoundTouch devices",
+ Action: func(c *cli.Context) error {
+ devices, err := discovery.DiscoverDevices()
+ if err != nil {
+ return err
+ }
+
+ for i, device := range devices {
+ fmt.Printf("%d: %s (%s)\n", i+1, device.Name, device.Host)
+ }
+ return nil
+ },
+ },
+ {
+ Name: "play",
+ Usage: "Send play command",
+ Flags: []cli.Flag{
+ &cli.StringFlag{Name: "device", Aliases: []string{"d"}},
+ },
+ Action: func(c *cli.Context) error {
+ client := getClientFromContext(c)
+ return client.SendKey(models.KeyPlay)
+ },
+ },
+ },
+ }
+
+ app.Run(os.Args)
+}
+
+func getClientFromContext(c *cli.Context) *client.Client {
+ deviceHost := c.String("device")
+ if deviceHost == "" {
+ // Interactive device selection
+ devices, _ := discovery.DiscoverDevices()
+ deviceHost = selectDeviceInteractive(devices)
+ }
+
+ return client.NewClient(deviceHost, 8090)
+}
+```
+
+## 7. Web Application with Embedded Assets
+
+### Single Binary Web Tool
+
+```go
+// cmd/webapp/main.go
+//go:embed web
+var webAssets embed.FS
+
+func main() {
+ mux := http.NewServeMux()
+
+ // Embedded web assets
+ webFS, err := fs.Sub(webAssets, "web")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // SPA routing
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.FileServer(http.FS(webFS)).ServeHTTP(w, r)
+ return
+ }
+
+ data, err := webAssets.ReadFile("web/index.html")
+ if err != nil {
+ http.Error(w, "Not found", http.StatusNotFound)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/html")
+ w.Write(data)
+ })
+
+ // API endpoints
+ mux.HandleFunc("/api/devices", handleDeviceDiscovery)
+ mux.HandleFunc("/api/client/", handleClientProxy)
+
+ log.Println("SoundTouch Web UI starting on :8080")
+ log.Fatal(http.ListenAndServe(":8080", mux))
+}
+```
+
+### CORS Proxy for Browser Restrictions
+
+```go
+func handleClientProxy(w http.ResponseWriter, r *http.Request) {
+ // Extract device IP from path: /api/client/192.168.1.100/now_playing
+ pathParts := strings.Split(r.URL.Path, "/")
+ if len(pathParts) < 5 {
+ http.Error(w, "Invalid path", http.StatusBadRequest)
+ return
+ }
+
+ deviceIP := pathParts[3]
+ apiPath := "/" + strings.Join(pathParts[4:], "/")
+
+ // Proxy request to SoundTouch device
+ targetURL := fmt.Sprintf("http://%s:8090%s", deviceIP, apiPath)
+
+ proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Copy headers
+ for k, v := range r.Header {
+ proxyReq.Header[k] = v
+ }
+
+ resp, err := http.DefaultClient.Do(proxyReq)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ defer resp.Body.Close()
+
+ // Enable CORS
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+
+ // Copy response
+ w.WriteHeader(resp.StatusCode)
+ io.Copy(w, resp.Body)
+}
+```
+
+## 8. Robust XML Model Definition
+
+### Structured Data Models
+
+```go
+// pkg/models/nowplaying.go
+type NowPlaying struct {
+ XMLName xml.Name `xml:"nowPlaying"`
+ DeviceID string `xml:"deviceID,attr"`
+ Source string `xml:"source,attr"`
+ Content ContentItem `xml:"ContentItem"`
+ Track string `xml:"track"`
+ Artist string `xml:"artist"`
+ Album string `xml:"album"`
+ Art Art `xml:"art"`
+ PlayStatus PlayStatus `xml:"playStatus"`
+ Position Position `xml:"position,omitempty"`
+}
+
+type ContentItem struct {
+ Source string `xml:"source,attr"`
+ Type string `xml:"type,attr"`
+ Location string `xml:"location,attr"`
+ SourceAccount string `xml:"sourceAccount,attr"`
+ ItemName string `xml:"itemName"`
+ ContainerArt string `xml:"containerArt"`
+}
+
+type PlayStatus string
+
+const (
+ PlayStatusPlaying PlayStatus = "PLAY_STATE"
+ PlayStatusPaused PlayStatus = "PAUSE_STATE"
+ PlayStatusStopped PlayStatus = "STOP_STATE"
+)
+
+// Custom unmarshaling for enum validation
+func (p *PlayStatus) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
+ var s string
+ if err := d.DecodeElement(&s, &start); err != nil {
+ return err
+ }
+
+ switch s {
+ case string(PlayStatusPlaying), string(PlayStatusPaused), string(PlayStatusStopped):
+ *p = PlayStatus(s)
+ default:
+ *p = PlayStatusStopped // Default fallback
+ }
+ return nil
+}
+```
+
+## 9. Configuration Management for Multi-Environment
+
+### Environment-based Configuration
+
+```go
+// pkg/config/config.go
+type Config struct {
+ // Server configuration
+ WebPort int `env:"WEB_PORT" default:"8080"`
+ APITimeout time.Duration `env:"API_TIMEOUT" default:"10s"`
+
+ // Discovery configuration
+ DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"5s"`
+ CacheDevices bool `env:"CACHE_DEVICES" default:"true"`
+
+ // CORS configuration (for web proxy)
+ CORSOrigins []string `env:"CORS_ORIGINS" default:"*"`
+
+ // Logging
+ LogLevel string `env:"LOG_LEVEL" default:"info"`
+}
+
+func Load() Config {
+ var cfg Config
+
+ // Load from .env file
+ loadDotEnv()
+
+ // Parse environment variables with reflection
+ parseEnvVars(&cfg)
+
+ return cfg
+}
+
+func parseEnvVars(cfg interface{}) {
+ v := reflect.ValueOf(cfg).Elem()
+ t := v.Type()
+
+ for i := 0; i < v.NumField(); i++ {
+ field := v.Field(i)
+ fieldType := t.Field(i)
+
+ envTag := fieldType.Tag.Get("env")
+ defaultTag := fieldType.Tag.Get("default")
+
+ if envTag != "" {
+ if envValue := os.Getenv(envTag); envValue != "" {
+ setFieldValue(field, envValue)
+ } else if defaultTag != "" {
+ setFieldValue(field, defaultTag)
+ }
+ }
+ }
+}
+```
+
+## 10. Testing Pattern for Hardware API
+
+### Mock-based Unit Tests
+
+```go
+// internal/testing/mock_client.go
+type MockClient struct {
+ responses map[string]interface{}
+ errors map[string]error
+}
+
+func NewMockClient() *MockClient {
+ return &MockClient{
+ responses: make(map[string]interface{}),
+ errors: make(map[string]error),
+ }
+}
+
+func (m *MockClient) SetResponse(endpoint string, response interface{}) {
+ m.responses[endpoint] = response
+}
+
+func (m *MockClient) SetError(endpoint string, err error) {
+ m.errors[endpoint] = err
+}
+
+func (m *MockClient) GetNowPlaying() (*models.NowPlaying, error) {
+ if err, exists := m.errors["now_playing"]; exists {
+ return nil, err
+ }
+
+ if resp, exists := m.responses["now_playing"]; exists {
+ return resp.(*models.NowPlaying), nil
+ }
+
+ return &models.NowPlaying{
+ Track: "Mock Track",
+ Artist: "Mock Artist",
+ Album: "Mock Album",
+ }, nil
+}
+```
+
+### Integration Tests with Docker
+
+```dockerfile
+# test/docker/Dockerfile
+FROM golang:1.21-alpine
+
+WORKDIR /app
+COPY . .
+
+# Install test dependencies
+RUN go mod download
+
+# Run tests
+CMD ["go", "test", "-v", "./..."]
+```
+
+```bash
+# Makefile test target
+test-integration:
+ docker-compose -f test/docker-compose.yml up --build --abort-on-container-exit
+ docker-compose -f test/docker-compose.yml down
+```
+
+## Recommended Project Structure
+
+```
+bose-soundtouch/
+βββ cmd/
+β βββ cli/ # CLI Tool
+β β βββ main.go
+β βββ webapp/ # Web Application
+β β βββ main.go
+β β βββ web/ # Embedded Assets
+β β βββ index.html
+β β βββ app.js
+β β βββ style.css
+β βββ wasm/ # WASM Entry Point
+β βββ main.go
+βββ pkg/ # Public API
+β βββ client/ # HTTP Client
+β βββ discovery/ # Device Discovery
+β βββ models/ # XML Data Models
+β βββ websocket/ # Event Streaming
+β βββ wasm/ # WASM Bindings
+βββ internal/ # Private Implementation
+β βββ xml/ # XML Utilities
+β βββ http/ # HTTP Utilities
+β βββ testing/ # Test Utilities
+βββ web/ # Frontend Assets (source)
+β βββ src/
+β βββ dist/ # Built assets β cmd/webapp/web/
+βββ examples/ # Usage Examples
+βββ test/ # Integration Tests
+βββ Makefile # Build Automation
+βββ .env.example # Configuration Template
+βββ go.mod
+βββ README.md
+```
+
+## Build System for Multi-Target
+
+### Makefile with Cross-Platform Support
+
+```makefile
+BINARY_NAME=soundtouch
+VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
+BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S')
+GO_VERSION=$(shell go version | cut -d ' ' -f 3)
+
+LDFLAGS=-ldflags "-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -X main.GoVersion=$(GO_VERSION)"
+BUILD_FLAGS=-trimpath $(LDFLAGS)
+
+# Standard builds
+build:
+ go build $(BUILD_FLAGS) -o $(BINARY_NAME) ./cmd/cli
+
+build-webapp:
+ go build $(BUILD_FLAGS) -o $(BINARY_NAME)-webapp ./cmd/webapp
+
+# WASM build
+build-wasm:
+ GOOS=js GOARCH=wasm go build $(BUILD_FLAGS) -o web/soundtouch.wasm ./cmd/wasm
+ cp "$(shell go env GOROOT)/misc/wasm/wasm_exec.js" web/
+
+# Cross-platform builds
+build-all: build-linux build-darwin build-windows
+
+build-linux:
+ GOOS=linux GOARCH=amd64 go build $(BUILD_FLAGS) -o $(BINARY_NAME)-linux-amd64 ./cmd/cli
+
+build-darwin:
+ GOOS=darwin GOARCH=amd64 go build $(BUILD_FLAGS) -o $(BINARY_NAME)-darwin-amd64 ./cmd/cli
+ GOOS=darwin GOARCH=arm64 go build $(BUILD_FLAGS) -o $(BINARY_NAME)-darwin-arm64 ./cmd/cli
+
+build-windows:
+ GOOS=windows GOARCH=amd64 go build $(BUILD_FLAGS) -o $(BINARY_NAME)-windows-amd64.exe ./cmd/cli
+
+# Development
+dev-webapp:
+ air -c .air-webapp.toml
+
+dev-wasm:
+ GOOS=js GOARCH=wasm go build -o web/soundtouch.wasm ./cmd/wasm
+ cd web && python3 -m http.server 8080
+
+# Testing
+test:
+ go test -v ./...
+
+test-coverage:
+ go test -v -coverprofile=coverage.out ./...
+ go tool cover -html=coverage.out -o coverage.html
+
+# Linting and formatting
+check: fmt vet lint test
+
+fmt:
+ go fmt ./...
+
+vet:
+ go vet ./...
+
+lint:
+ golangci-lint run
+
+# Cleanup
+clean:
+ rm -f $(BINARY_NAME)*
+ rm -f web/soundtouch.wasm web/wasm_exec.js
+ rm -f coverage.out coverage.html
+
+.PHONY: build build-webapp build-wasm build-all dev-webapp dev-wasm test check clean
+```
+
+## Reusable Patterns for API Client Projects
+
+### 1. Basic Setup for XML-API Client
+
+**Step 1:** Create project structure
+```bash
+mkdir -p cmd/{cli,webapp/web,wasm}
+mkdir -p pkg/{client,discovery,models,websocket,wasm}
+mkdir -p internal/{xml,http,testing}
+mkdir -p examples test web/src
+```
+
+**Step 2:** Initialize Go module
+```bash
+go mod init github.com/username/api-client
+go get github.com/gorilla/websocket
+go get github.com/urfave/cli/v2
+```
+
+**Step 3:** Create basic HTTP client
+```go
+// pkg/client/client.go
+type Client struct {
+ baseURL string
+ httpClient *http.Client
+}
+
+func NewClient(baseURL string) *Client {
+ return &Client{
+ baseURL: baseURL,
+ httpClient: &http.Client{Timeout: 10 * time.Second},
+ }
+}
+```
+
+### 2. XML Models Pattern
+
+```go
+// pkg/models/base.go
+type XMLResponse struct {
+ XMLName xml.Name `xml:",innerxml"`
+ Error *APIError `xml:"error,omitempty"`
+}
+
+type APIError struct {
+ Code string `xml:"code,attr"`
+ Message string `xml:",innerxml"`
+}
+
+// pkg/models/device.go
+type DeviceInfo struct {
+ XMLResponse
+ Name string `xml:"name"`
+ Type string `xml:"type"`
+ DeviceID string `xml:"deviceID,attr"`
+}
+```
+
+### 3. CLI Framework
+
+```go
+// cmd/cli/main.go
+func main() {
+ app := &cli.App{
+ Name: "api-client",
+ Usage: "API Client Tool",
+ Version: Version,
+ Commands: []*cli.Command{
+ {
+ Name: "discover",
+ Usage: "Discover devices",
+ Action: discoverCommand,
+ },
+ {
+ Name: "status",
+ Usage: "Get device status",
+ Flags: deviceFlags,
+ Action: statusCommand,
+ },
+ },
+ }
+
+ app.Run(os.Args)
+}
+```
+
+## Advantages of This Pattern Approach
+
+### 1. Multi-Platform Deployment
+- **Native Binaries**: Optimal performance for server/CLI
+- **WASM Support**: Browser integration without backend
+- **Cross-Platform**: One codebase for all systems
+
+### 2. API Client Best Practices
+- **Type Safety**: Strict XML-to-Go mappings
+- **Error Handling**: Structured error handling
+- **Timeout Management**: Robust network calls
+
+### 3. Developer Experience
+- **Hot Reload**: Live updates during development
+- **Mock Testing**: Hardware-independent testing
+- **Comprehensive Tooling**: Build, test, lint automated
+
+### 4. Production Ready
+- **Graceful Shutdown**: Clean resource release
+- **Structured Logging**: Monitoring-friendly logs
+- **Configuration Management**: Environment-based config
+
+## Conclusion
+
+This pattern collection enables the development of robust API clients for hardware devices that function both as native tools and as web applications. The combination of Go's type safety, WASM support, and a structured build system makes it possible to use a single codebase for various deployment scenarios.
\ No newline at end of file
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..332e94f
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module github.com/user_account/bose-soundtouch
+
+go 1.25.5
diff --git a/pkg/client/client.go b/pkg/client/client.go
new file mode 100644
index 0000000..eeca382
--- /dev/null
+++ b/pkg/client/client.go
@@ -0,0 +1,187 @@
+package client
+
+import (
+ "bytes"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/user_account/bose-soundtouch/pkg/models"
+)
+
+// Client represents a SoundTouch API client
+type Client struct {
+ baseURL string
+ httpClient *http.Client
+ timeout time.Duration
+ userAgent string
+}
+
+// ClientConfig holds configuration for the SoundTouch client
+type ClientConfig struct {
+ Host string
+ Port int
+ Timeout time.Duration
+ UserAgent string
+}
+
+// DefaultConfig returns a default client configuration
+func DefaultConfig() ClientConfig {
+ return ClientConfig{
+ Host: "localhost",
+ Port: 8090,
+ Timeout: 10 * time.Second,
+ UserAgent: "Bose-SoundTouch-Go-Client/1.0",
+ }
+}
+
+// NewClient creates a new SoundTouch API client
+func NewClient(config ClientConfig) *Client {
+ if config.Timeout == 0 {
+ config.Timeout = 10 * time.Second
+ }
+ if config.UserAgent == "" {
+ config.UserAgent = "Bose-SoundTouch-Go-Client/1.0"
+ }
+ if config.Port == 0 {
+ config.Port = 8090
+ }
+
+ return &Client{
+ baseURL: fmt.Sprintf("http://%s:%d", config.Host, config.Port),
+ httpClient: &http.Client{
+ Timeout: config.Timeout,
+ },
+ timeout: config.Timeout,
+ userAgent: config.UserAgent,
+ }
+}
+
+// NewClientFromHost creates a new client with just a host address
+func NewClientFromHost(host string) *Client {
+ config := DefaultConfig()
+ config.Host = host
+ return NewClient(config)
+}
+
+// GetDeviceInfo retrieves device information from the /info endpoint
+func (c *Client) GetDeviceInfo() (*models.DeviceInfo, error) {
+ var deviceInfo models.DeviceInfo
+ err := c.get("/info", &deviceInfo)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get device info: %w", err)
+ }
+ return &deviceInfo, nil
+}
+
+// Ping checks if the device is reachable by calling /info
+func (c *Client) Ping() error {
+ _, err := c.GetDeviceInfo()
+ return err
+}
+
+// BaseURL returns the base URL for this client
+func (c *Client) BaseURL() string {
+ return c.baseURL
+}
+
+// Host returns the host for this client
+func (c *Client) Host() string {
+ return c.baseURL
+}
+
+// get performs a GET request and unmarshals the XML response
+func (c *Client) get(endpoint string, result interface{}) error {
+ url := c.baseURL + endpoint
+
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("User-Agent", c.userAgent)
+ req.Header.Set("Accept", "application/xml")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to execute request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ // Parse the actual response first
+ if err := xml.Unmarshal(body, result); err != nil {
+ // Check if it might be an API error response instead
+ var apiError models.APIError
+ if xmlErr := xml.Unmarshal(body, &apiError); xmlErr == nil && apiError.Message != "" {
+ return &apiError
+ }
+ return fmt.Errorf("failed to unmarshal XML response: %w", err)
+ }
+
+ return nil
+}
+
+// post performs a POST request with XML body
+func (c *Client) post(endpoint string, payload interface{}, result interface{}) error {
+ url := c.baseURL + endpoint
+
+ var body io.Reader
+ if payload != nil {
+ xmlData, err := xml.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("failed to marshal XML request: %w", err)
+ }
+ body = bytes.NewReader(xmlData)
+ }
+
+ req, err := http.NewRequest("POST", url, body)
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("User-Agent", c.userAgent)
+ req.Header.Set("Content-Type", "application/xml")
+ req.Header.Set("Accept", "application/xml")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to execute request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ responseBody, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(responseBody))
+ }
+
+ if result != nil {
+ responseBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ // Parse the actual response first
+ if err := xml.Unmarshal(responseBody, result); err != nil {
+ // Check if it might be an API error response instead
+ var apiError models.APIError
+ if xmlErr := xml.Unmarshal(responseBody, &apiError); xmlErr == nil && apiError.Message != "" {
+ return &apiError
+ }
+ return fmt.Errorf("failed to unmarshal XML response: %w", err)
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go
new file mode 100644
index 0000000..99881fd
--- /dev/null
+++ b/pkg/client/client_test.go
@@ -0,0 +1,314 @@
+package client
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestNewClient(t *testing.T) {
+ config := ClientConfig{
+ Host: "192.168.1.100",
+ Port: 8090,
+ Timeout: 15 * time.Second,
+ }
+
+ client := NewClient(config)
+
+ if client.baseURL != "http://192.168.1.100:8090" {
+ t.Errorf("Expected baseURL 'http://192.168.1.100:8090', got '%s'", client.baseURL)
+ }
+
+ if client.timeout != 15*time.Second {
+ t.Errorf("Expected timeout 15s, got %v", client.timeout)
+ }
+
+ if client.httpClient.Timeout != 15*time.Second {
+ t.Errorf("Expected HTTP client timeout 15s, got %v", client.httpClient.Timeout)
+ }
+}
+
+func TestNewClientWithDefaults(t *testing.T) {
+ config := ClientConfig{
+ Host: "192.168.1.100",
+ }
+
+ client := NewClient(config)
+
+ if client.baseURL != "http://192.168.1.100:8090" {
+ t.Errorf("Expected default port 8090 in baseURL, got '%s'", client.baseURL)
+ }
+
+ if client.timeout != 10*time.Second {
+ t.Errorf("Expected default timeout 10s, got %v", client.timeout)
+ }
+
+ if client.userAgent != "Bose-SoundTouch-Go-Client/1.0" {
+ t.Errorf("Expected default user agent, got '%s'", client.userAgent)
+ }
+}
+
+func TestNewClientFromHost(t *testing.T) {
+ client := NewClientFromHost("192.168.1.200")
+
+ expected := "http://192.168.1.200:8090"
+ if client.baseURL != expected {
+ t.Errorf("Expected baseURL '%s', got '%s'", expected, client.baseURL)
+ }
+}
+
+func TestGetDeviceInfo_Success(t *testing.T) {
+ // Load test data
+ testData := loadTestData(t, "info_response.xml")
+
+ // Create mock server
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Verify request
+ if r.URL.Path != "/info" {
+ t.Errorf("Expected path '/info', got '%s'", r.URL.Path)
+ }
+ if r.Method != "GET" {
+ t.Errorf("Expected method GET, got %s", r.Method)
+ }
+
+ // Check headers
+ if r.Header.Get("Accept") != "application/xml" {
+ t.Errorf("Expected Accept header 'application/xml', got '%s'", r.Header.Get("Accept"))
+ }
+ if r.Header.Get("User-Agent") != "Bose-SoundTouch-Go-Client/1.0" {
+ t.Errorf("Expected User-Agent header, got '%s'", r.Header.Get("User-Agent"))
+ }
+
+ // Send response
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(testData))
+ }))
+ defer server.Close()
+
+ // Create client pointing to mock server
+ client := createTestClient(server.URL)
+
+ // Test GetDeviceInfo
+ deviceInfo, err := client.GetDeviceInfo()
+ if err != nil {
+ t.Fatalf("Expected no error, got: %v", err)
+ }
+
+ // Verify response parsing
+ if deviceInfo.DeviceID != "A81B6A536A98" {
+ t.Errorf("Expected DeviceID 'A81B6A536A98', got '%s'", deviceInfo.DeviceID)
+ }
+
+ if deviceInfo.Type != "SoundTouch 10" {
+ t.Errorf("Expected Type 'SoundTouch 10', got '%s'", deviceInfo.Type)
+ }
+
+ if deviceInfo.Name != "Sound Machinechen" {
+ t.Errorf("Expected Name 'Sound Machinechen', got '%s'", deviceInfo.Name)
+ }
+
+ if deviceInfo.MargeAccountUUID != "3230304" {
+ t.Errorf("Expected MargeAccountUUID '3230304', got '%s'", deviceInfo.MargeAccountUUID)
+ }
+
+ if deviceInfo.ModuleType != "sm2" {
+ t.Errorf("Expected ModuleType 'sm2', got '%s'", deviceInfo.ModuleType)
+ }
+
+ if len(deviceInfo.Components) != 2 {
+ t.Errorf("Expected 2 components, got %d", len(deviceInfo.Components))
+ }
+
+ // Check first component
+ if len(deviceInfo.Components) > 0 {
+ comp := deviceInfo.Components[0]
+ if comp.ComponentCategory != "SCM" {
+ t.Errorf("Expected first component category 'SCM', got '%s'", comp.ComponentCategory)
+ }
+ if comp.SerialNumber != "I6332527703739342000020" {
+ t.Errorf("Expected first component serial 'I6332527703739342000020', got '%s'", comp.SerialNumber)
+ }
+ }
+
+ // Check network info
+ if len(deviceInfo.NetworkInfo) != 2 {
+ t.Errorf("Expected 2 network info entries, got %d", len(deviceInfo.NetworkInfo))
+ }
+
+ if len(deviceInfo.NetworkInfo) > 0 {
+ net := deviceInfo.NetworkInfo[0]
+ if net.Type != "SCM" {
+ t.Errorf("Expected first network type 'SCM', got '%s'", net.Type)
+ }
+ if net.IPAddress != "192.168.1.35" {
+ t.Errorf("Expected IP address '192.168.1.35', got '%s'", net.IPAddress)
+ }
+ }
+}
+
+func TestGetDeviceInfo_HTTPError(t *testing.T) {
+ // Create mock server that returns 404
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ w.Write([]byte("Not Found"))
+ }))
+ defer server.Close()
+
+ client := createTestClient(server.URL)
+
+ // Test GetDeviceInfo with 404 response
+ _, err := client.GetDeviceInfo()
+ if err == nil {
+ t.Fatal("Expected error for 404 response, got nil")
+ }
+
+ expectedError := "API request failed with status 404"
+ if !contains(err.Error(), expectedError) {
+ t.Errorf("Expected error to contain '%s', got '%s'", expectedError, err.Error())
+ }
+}
+
+func TestGetDeviceInfo_InvalidXML(t *testing.T) {
+ // Create mock server that returns invalid XML
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("invalid xml content"))
+ }))
+ defer server.Close()
+
+ client := createTestClient(server.URL)
+
+ // Test GetDeviceInfo with invalid XML
+ _, err := client.GetDeviceInfo()
+ if err == nil {
+ t.Fatal("Expected error for invalid XML, got nil")
+ }
+
+ expectedError := "failed to unmarshal XML response"
+ if !contains(err.Error(), expectedError) {
+ t.Errorf("Expected error to contain '%s', got '%s'", expectedError, err.Error())
+ }
+}
+
+func TestGetDeviceInfo_APIError(t *testing.T) {
+ // Create mock server that returns API error
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`Device not found`))
+ }))
+ defer server.Close()
+
+ client := createTestClient(server.URL)
+
+ // Test GetDeviceInfo with API error
+ _, err := client.GetDeviceInfo()
+ if err == nil {
+ t.Fatal("Expected API error, got nil")
+ }
+
+ // The error gets wrapped by GetDeviceInfo, so check the error message content
+ if !contains(err.Error(), "Device not found") {
+ t.Errorf("Expected error to contain 'Device not found', got '%s'", err.Error())
+ }
+}
+
+func TestPing_Success(t *testing.T) {
+ testData := loadTestData(t, "info_response.xml")
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(testData))
+ }))
+ defer server.Close()
+
+ client := createTestClient(server.URL)
+
+ err := client.Ping()
+ if err != nil {
+ t.Errorf("Expected successful ping, got error: %v", err)
+ }
+}
+
+func TestPing_Failure(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer server.Close()
+
+ client := createTestClient(server.URL)
+
+ err := client.Ping()
+ if err == nil {
+ t.Error("Expected ping to fail, but got no error")
+ }
+}
+
+func TestBaseURL(t *testing.T) {
+ client := NewClientFromHost("192.168.1.100")
+ expected := "http://192.168.1.100:8090"
+
+ if client.BaseURL() != expected {
+ t.Errorf("Expected BaseURL '%s', got '%s'", expected, client.BaseURL())
+ }
+}
+
+func TestClientTimeout(t *testing.T) {
+ // Create a server that delays response
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ time.Sleep(200 * time.Millisecond)
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(``))
+ }))
+ defer server.Close()
+
+ // Create client with short timeout
+ config := DefaultConfig()
+ config.Timeout = 100 * time.Millisecond
+ client := NewClient(config)
+ client.baseURL = server.URL
+
+ // Test that request times out
+ _, err := client.GetDeviceInfo()
+ if err == nil {
+ t.Error("Expected timeout error, got nil")
+ }
+
+ expectedError := "deadline exceeded"
+ if !contains(err.Error(), expectedError) {
+ t.Errorf("Expected error to contain '%s', got '%s'", expectedError, err.Error())
+ }
+}
+
+// Helper functions
+
+func loadTestData(t *testing.T, filename string) string {
+ t.Helper()
+
+ path := filepath.Join("testdata", filename)
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("Failed to load test data %s: %v", filename, err)
+ }
+
+ return string(data)
+}
+
+func createTestClient(serverURL string) *Client {
+ config := DefaultConfig()
+ config.Host = "localhost" // Will be overridden by baseURL
+ client := NewClient(config)
+ client.baseURL = serverURL
+ return client
+}
+
+func contains(s, substr string) bool {
+ return strings.Contains(s, substr)
+}
diff --git a/pkg/client/testdata/info_response.xml b/pkg/client/testdata/info_response.xml
new file mode 100644
index 0000000..32beaf0
--- /dev/null
+++ b/pkg/client/testdata/info_response.xml
@@ -0,0 +1,32 @@
+
+
+ Sound Machinechen
+ SoundTouch 10
+ 3230304
+
+
+ SCM
+ 27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29
+ I6332527703739342000020
+
+
+ PackagedProduct
+ 27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29
+ 069231P63364828AE
+
+
+ https://streaming.bose.com
+
+ A81B6A536A98
+ 192.168.1.35
+
+
+ A81B6A849D99
+ 192.168.1.35
+
+ sm2
+ rhino
+ normal
+ GB
+ GB
+
diff --git a/pkg/client/testdata/info_response_st20.xml b/pkg/client/testdata/info_response_st20.xml
new file mode 100644
index 0000000..54c4fa4
--- /dev/null
+++ b/pkg/client/testdata/info_response_st20.xml
@@ -0,0 +1,40 @@
+
+
+ My SoundTouch Device
+ SoundTouch 20
+ 3230304
+
+
+ SCM
+ 27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29
+ K4245112804625125000710
+
+
+ PackagedProduct
+ 066802942560222AE
+
+
+ Lightswitch
+
+
+
+ SMSC
+ I2014102015199423; B unknown 081008
+ 08DF1F0BA32A
+
+
+ https://streaming.bose.com
+
+ 1234567890AB
+ 192.168.1.100
+
+
+ 08DF1F0BA32A
+ 192.168.1.100
+
+ scm
+ spotty
+ normal
+ EU
+
+
diff --git a/pkg/config/config.go b/pkg/config/config.go
new file mode 100644
index 0000000..653a288
--- /dev/null
+++ b/pkg/config/config.go
@@ -0,0 +1,261 @@
+package config
+
+import (
+ "bufio"
+ "fmt"
+ "net"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/user_account/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"`
+
+ // 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"`
+}
+
+// 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,
+ PreferredDevices: []DeviceConfig{},
+ HTTPTimeout: 10 * time.Second,
+ UserAgent: "Bose-SoundTouch-Go-Client/1.0",
+ 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
+ if err := loadDotEnv(); err != nil {
+ // 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 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,
+ Location: fmt.Sprintf("http://%s:%d/info", device.Host, device.Port),
+ LastSeen: time.Now(),
+ }
+ 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
+}
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
new file mode 100644
index 0000000..92c7290
--- /dev/null
+++ b/pkg/config/config_test.go
@@ -0,0 +1,422 @@
+package config
+
+import (
+ "os"
+ "testing"
+ "time"
+)
+
+func TestDefaultConfig(t *testing.T) {
+ config := DefaultConfig()
+
+ if config.DiscoveryTimeout != 5*time.Second {
+ t.Errorf("Expected discovery timeout 5s, got %v", config.DiscoveryTimeout)
+ }
+
+ if !config.UPnPEnabled {
+ t.Error("Expected UPnP to be enabled by default")
+ }
+
+ if config.HTTPTimeout != 10*time.Second {
+ t.Errorf("Expected HTTP timeout 10s, got %v", config.HTTPTimeout)
+ }
+
+ if config.UserAgent != "Bose-SoundTouch-Go-Client/1.0" {
+ t.Errorf("Expected default user agent, got %s", config.UserAgent)
+ }
+
+ if !config.CacheEnabled {
+ t.Error("Expected cache to be enabled by default")
+ }
+
+ if config.CacheTTL != 30*time.Second {
+ t.Errorf("Expected cache TTL 30s, got %v", config.CacheTTL)
+ }
+
+ if len(config.PreferredDevices) != 0 {
+ t.Errorf("Expected no preferred devices by default, got %d", len(config.PreferredDevices))
+ }
+}
+
+func TestLoadFromEnv_NoEnvVars(t *testing.T) {
+ // Clear relevant environment variables
+ clearTestEnvVars()
+
+ config, err := LoadFromEnv()
+ if err != nil {
+ t.Fatalf("Expected no error, got %v", err)
+ }
+
+ // Should have default values
+ if config.DiscoveryTimeout != 5*time.Second {
+ t.Errorf("Expected default discovery timeout, got %v", config.DiscoveryTimeout)
+ }
+
+ if !config.UPnPEnabled {
+ t.Error("Expected UPnP enabled by default")
+ }
+}
+
+func TestLoadFromEnv_WithEnvVars(t *testing.T) {
+ clearTestEnvVars()
+
+ // Set test environment variables
+ os.Setenv("DISCOVERY_TIMEOUT", "15s")
+ os.Setenv("UPNP_ENABLED", "false")
+ os.Setenv("HTTP_TIMEOUT", "20s")
+ os.Setenv("USER_AGENT", "Test-Client/1.0")
+ os.Setenv("CACHE_ENABLED", "false")
+ os.Setenv("CACHE_TTL", "60s")
+
+ defer clearTestEnvVars()
+
+ config, err := LoadFromEnv()
+ if err != nil {
+ t.Fatalf("Expected no error, got %v", err)
+ }
+
+ if config.DiscoveryTimeout != 15*time.Second {
+ t.Errorf("Expected discovery timeout 15s, got %v", config.DiscoveryTimeout)
+ }
+
+ if config.UPnPEnabled {
+ t.Error("Expected UPnP to be disabled")
+ }
+
+ if config.HTTPTimeout != 20*time.Second {
+ t.Errorf("Expected HTTP timeout 20s, got %v", config.HTTPTimeout)
+ }
+
+ if config.UserAgent != "Test-Client/1.0" {
+ t.Errorf("Expected custom user agent, got %s", config.UserAgent)
+ }
+
+ if config.CacheEnabled {
+ t.Error("Expected cache to be disabled")
+ }
+
+ if config.CacheTTL != 60*time.Second {
+ t.Errorf("Expected cache TTL 60s, got %v", config.CacheTTL)
+ }
+}
+
+func TestParseDeviceString_HostOnly(t *testing.T) {
+ device, err := parseDeviceString("192.168.1.100")
+ if err != nil {
+ t.Fatalf("Expected no error, got %v", err)
+ }
+
+ if device.Host != "192.168.1.100" {
+ t.Errorf("Expected host '192.168.1.100', got '%s'", device.Host)
+ }
+
+ if device.Port != 8090 {
+ t.Errorf("Expected default port 8090, got %d", device.Port)
+ }
+
+ if device.Name != "SoundTouch-192.168.1.100" {
+ t.Errorf("Expected default name 'SoundTouch-192.168.1.100', got '%s'", device.Name)
+ }
+}
+
+func TestParseDeviceString_HostPort(t *testing.T) {
+ device, err := parseDeviceString("192.168.1.100:8091")
+ if err != nil {
+ t.Fatalf("Expected no error, got %v", err)
+ }
+
+ if device.Host != "192.168.1.100" {
+ t.Errorf("Expected host '192.168.1.100', got '%s'", device.Host)
+ }
+
+ if device.Port != 8091 {
+ t.Errorf("Expected port 8091, got %d", device.Port)
+ }
+
+ if device.Name != "SoundTouch-192.168.1.100" {
+ t.Errorf("Expected default name 'SoundTouch-192.168.1.100', got '%s'", device.Name)
+ }
+}
+
+func TestParseDeviceString_NameHostPort(t *testing.T) {
+ device, err := parseDeviceString("Living Room@192.168.1.100:8090")
+ if err != nil {
+ t.Fatalf("Expected no error, got %v", err)
+ }
+
+ if device.Host != "192.168.1.100" {
+ t.Errorf("Expected host '192.168.1.100', got '%s'", device.Host)
+ }
+
+ if device.Port != 8090 {
+ t.Errorf("Expected port 8090, got %d", device.Port)
+ }
+
+ if device.Name != "Living Room" {
+ t.Errorf("Expected name 'Living Room', got '%s'", device.Name)
+ }
+}
+
+func TestParseDeviceString_NameHost(t *testing.T) {
+ device, err := parseDeviceString("Kitchen Speaker@192.168.1.101")
+ if err != nil {
+ t.Fatalf("Expected no error, got %v", err)
+ }
+
+ if device.Host != "192.168.1.101" {
+ t.Errorf("Expected host '192.168.1.101', got '%s'", device.Host)
+ }
+
+ if device.Port != 8090 {
+ t.Errorf("Expected default port 8090, got %d", device.Port)
+ }
+
+ if device.Name != "Kitchen Speaker" {
+ t.Errorf("Expected name 'Kitchen Speaker', got '%s'", device.Name)
+ }
+}
+
+func TestParseDeviceString_InvalidPort(t *testing.T) {
+ _, err := parseDeviceString("192.168.1.100:invalid")
+ if err == nil {
+ t.Error("Expected error for invalid port, got nil")
+ }
+
+ _, err = parseDeviceString("192.168.1.100:0")
+ if err == nil {
+ t.Error("Expected error for port 0, got nil")
+ }
+
+ _, err = parseDeviceString("192.168.1.100:70000")
+ if err == nil {
+ t.Error("Expected error for port > 65535, got nil")
+ }
+}
+
+func TestParseDeviceString_EmptyHost(t *testing.T) {
+ _, err := parseDeviceString("")
+ if err == nil {
+ t.Error("Expected error for empty string, got nil")
+ }
+
+ _, err = parseDeviceString("@")
+ if err == nil {
+ t.Error("Expected error for empty host with @, got nil")
+ }
+}
+
+func TestParsePreferredDevices_SingleDevice(t *testing.T) {
+ clearTestEnvVars()
+ os.Setenv("PREFERRED_DEVICES", "192.168.1.100")
+ defer clearTestEnvVars()
+
+ devices, err := parsePreferredDevices()
+ if err != nil {
+ t.Fatalf("Expected no error, got %v", err)
+ }
+
+ if len(devices) != 1 {
+ t.Errorf("Expected 1 device, got %d", len(devices))
+ }
+
+ if devices[0].Host != "192.168.1.100" {
+ t.Errorf("Expected host '192.168.1.100', got '%s'", devices[0].Host)
+ }
+}
+
+func TestParsePreferredDevices_MultipleDevices(t *testing.T) {
+ clearTestEnvVars()
+ os.Setenv("PREFERRED_DEVICES", "Living Room@192.168.1.100:8090;Kitchen@192.168.1.101;192.168.1.102:8091")
+ defer clearTestEnvVars()
+
+ devices, err := parsePreferredDevices()
+ if err != nil {
+ t.Fatalf("Expected no error, got %v", err)
+ }
+
+ if len(devices) != 3 {
+ t.Errorf("Expected 3 devices, got %d", len(devices))
+ }
+
+ // Check first device
+ if devices[0].Name != "Living Room" {
+ t.Errorf("Expected first device name 'Living Room', got '%s'", devices[0].Name)
+ }
+ if devices[0].Host != "192.168.1.100" {
+ t.Errorf("Expected first device host '192.168.1.100', got '%s'", devices[0].Host)
+ }
+ if devices[0].Port != 8090 {
+ t.Errorf("Expected first device port 8090, got %d", devices[0].Port)
+ }
+
+ // Check second device
+ if devices[1].Name != "Kitchen" {
+ t.Errorf("Expected second device name 'Kitchen', got '%s'", devices[1].Name)
+ }
+ if devices[1].Host != "192.168.1.101" {
+ t.Errorf("Expected second device host '192.168.1.101', got '%s'", devices[1].Host)
+ }
+ if devices[1].Port != 8090 {
+ t.Errorf("Expected second device port 8090, got %d", devices[1].Port)
+ }
+
+ // Check third device
+ if devices[2].Name != "SoundTouch-192.168.1.102" {
+ t.Errorf("Expected third device default name, got '%s'", devices[2].Name)
+ }
+ if devices[2].Host != "192.168.1.102" {
+ t.Errorf("Expected third device host '192.168.1.102', got '%s'", devices[2].Host)
+ }
+ if devices[2].Port != 8091 {
+ t.Errorf("Expected third device port 8091, got %d", devices[2].Port)
+ }
+}
+
+func TestParsePreferredDevices_EmptyString(t *testing.T) {
+ clearTestEnvVars()
+ os.Setenv("PREFERRED_DEVICES", "")
+ defer clearTestEnvVars()
+
+ devices, err := parsePreferredDevices()
+ if err != nil {
+ t.Fatalf("Expected no error, got %v", err)
+ }
+
+ if len(devices) != 0 {
+ t.Errorf("Expected 0 devices for empty string, got %d", len(devices))
+ }
+}
+
+func TestParsePreferredDevices_InvalidDevice(t *testing.T) {
+ clearTestEnvVars()
+ os.Setenv("PREFERRED_DEVICES", "192.168.1.100:invalid")
+ defer clearTestEnvVars()
+
+ _, err := parsePreferredDevices()
+ if err == nil {
+ t.Error("Expected error for invalid device configuration, got nil")
+ }
+}
+
+func TestGetPreferredDevicesAsDiscovered(t *testing.T) {
+ config := &Config{
+ PreferredDevices: []DeviceConfig{
+ {Name: "Living Room", Host: "192.168.1.100", Port: 8090},
+ {Name: "Kitchen", Host: "192.168.1.101", Port: 8091},
+ },
+ }
+
+ devices := config.GetPreferredDevicesAsDiscovered()
+
+ if len(devices) != 2 {
+ t.Errorf("Expected 2 discovered devices, got %d", len(devices))
+ }
+
+ // Check first device
+ if devices[0].Name != "Living Room" {
+ t.Errorf("Expected name 'Living Room', got '%s'", devices[0].Name)
+ }
+ if devices[0].Host != "192.168.1.100" {
+ t.Errorf("Expected host '192.168.1.100', got '%s'", devices[0].Host)
+ }
+ if devices[0].Port != 8090 {
+ 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)
+ }
+}
+
+func TestValidate_ValidConfig(t *testing.T) {
+ config := &Config{
+ DiscoveryTimeout: 5 * time.Second,
+ HTTPTimeout: 10 * time.Second,
+ CacheTTL: 30 * time.Second,
+ PreferredDevices: []DeviceConfig{
+ {Name: "Test", Host: "192.168.1.100", Port: 8090},
+ },
+ }
+
+ err := config.Validate()
+ if err != nil {
+ t.Errorf("Expected valid config, got error: %v", err)
+ }
+}
+
+func TestValidate_InvalidTimeouts(t *testing.T) {
+ config := &Config{
+ DiscoveryTimeout: 0,
+ HTTPTimeout: 10 * time.Second,
+ CacheTTL: 30 * time.Second,
+ }
+
+ err := config.Validate()
+ if err == nil {
+ t.Error("Expected error for zero discovery timeout, got nil")
+ }
+
+ config.DiscoveryTimeout = 5 * time.Second
+ config.HTTPTimeout = 0
+
+ err = config.Validate()
+ if err == nil {
+ t.Error("Expected error for zero HTTP timeout, got nil")
+ }
+
+ config.HTTPTimeout = 10 * time.Second
+ config.CacheTTL = 0
+
+ err = config.Validate()
+ if err == nil {
+ t.Error("Expected error for zero cache TTL, got nil")
+ }
+}
+
+func TestValidate_InvalidDevices(t *testing.T) {
+ config := &Config{
+ DiscoveryTimeout: 5 * time.Second,
+ HTTPTimeout: 10 * time.Second,
+ CacheTTL: 30 * time.Second,
+ PreferredDevices: []DeviceConfig{
+ {Name: "Test", Host: "", Port: 8090}, // Empty host
+ },
+ }
+
+ err := config.Validate()
+ if err == nil {
+ t.Error("Expected error for empty host, got nil")
+ }
+
+ config.PreferredDevices[0].Host = "192.168.1.100"
+ config.PreferredDevices[0].Port = 0 // Invalid port
+
+ err = config.Validate()
+ if err == nil {
+ t.Error("Expected error for invalid port, got nil")
+ }
+
+ config.PreferredDevices[0].Port = 70000 // Port too high
+
+ err = config.Validate()
+ if err == nil {
+ t.Error("Expected error for port > 65535, got nil")
+ }
+}
+
+// Helper function to clear test environment variables
+func clearTestEnvVars() {
+ envVars := []string{
+ "DISCOVERY_TIMEOUT",
+ "UPNP_ENABLED",
+ "HTTP_TIMEOUT",
+ "USER_AGENT",
+ "CACHE_ENABLED",
+ "CACHE_TTL",
+ "PREFERRED_DEVICES",
+ }
+
+ for _, env := range envVars {
+ os.Unsetenv(env)
+ }
+}
diff --git a/pkg/discovery/upnp.go b/pkg/discovery/upnp.go
new file mode 100644
index 0000000..aa7599b
--- /dev/null
+++ b/pkg/discovery/upnp.go
@@ -0,0 +1,387 @@
+package discovery
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/http"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/user_account/bose-soundtouch/pkg/config"
+ "github.com/user_account/bose-soundtouch/pkg/models"
+)
+
+const (
+ // SSDP multicast address and port
+ ssdpAddr = "239.255.255.250:1900"
+
+ // SoundTouch device URN
+ soundTouchURN = "urn:schemas-upnp-org:device:MediaRenderer:1"
+
+ // Default discovery timeout
+ defaultTimeout = 5 * time.Second
+
+ // Default cache TTL
+ defaultCacheTTL = 30 * time.Second
+)
+
+// DiscoveryService handles UPnP SSDP discovery of SoundTouch devices
+type DiscoveryService struct {
+ timeout time.Duration
+ cache map[string]*models.DiscoveredDevice
+ cacheTTL time.Duration
+ mutex sync.RWMutex
+ config *config.Config
+}
+
+// NewDiscoveryService creates a new UPnP discovery service
+func NewDiscoveryService(timeout time.Duration) *DiscoveryService {
+ if timeout == 0 {
+ timeout = defaultTimeout
+ }
+
+ return &DiscoveryService{
+ timeout: timeout,
+ cache: make(map[string]*models.DiscoveredDevice),
+ cacheTTL: defaultCacheTTL,
+ mutex: sync.RWMutex{},
+ config: config.DefaultConfig(),
+ }
+}
+
+// NewDiscoveryServiceWithConfig creates a new discovery service with configuration
+func NewDiscoveryServiceWithConfig(cfg *config.Config) *DiscoveryService {
+ timeout := cfg.DiscoveryTimeout
+ if timeout == 0 {
+ timeout = defaultTimeout
+ }
+
+ cacheTTL := cfg.CacheTTL
+ if cacheTTL == 0 {
+ cacheTTL = defaultCacheTTL
+ }
+
+ return &DiscoveryService{
+ timeout: timeout,
+ cache: make(map[string]*models.DiscoveredDevice),
+ cacheTTL: cacheTTL,
+ mutex: sync.RWMutex{},
+ config: cfg,
+ }
+}
+
+// DiscoverDevices discovers all SoundTouch devices on the network
+func (d *DiscoveryService) DiscoverDevices(ctx context.Context) ([]*models.DiscoveredDevice, error) {
+ // Check cache first
+ d.cleanupCache()
+ cached := d.getCachedDevices()
+ if len(cached) > 0 {
+ return cached, nil
+ }
+
+ var allDevices []*models.DiscoveredDevice
+
+ // Add configured devices first
+ configuredDevices := d.getConfiguredDevices()
+ allDevices = append(allDevices, configuredDevices...)
+
+ // Perform UPnP discovery if enabled
+ if d.config.UPnPEnabled {
+ upnpDevices, err := d.performDiscovery(ctx)
+ if err != nil {
+ // Don't fail completely if UPnP fails, just log and continue with configured devices
+ // We'll just use configured devices
+ } else {
+ // Merge UPnP devices, avoiding duplicates
+ allDevices = d.mergeDevices(allDevices, upnpDevices)
+ }
+ }
+
+ // Update cache
+ d.updateCache(allDevices)
+
+ return allDevices, nil
+}
+
+// DiscoverDevice discovers a specific SoundTouch device by host
+func (d *DiscoveryService) DiscoverDevice(ctx context.Context, host string) (*models.DiscoveredDevice, error) {
+ // Check cache first
+ d.mutex.RLock()
+ if device, exists := d.cache[host]; exists && time.Since(device.LastSeen) < d.cacheTTL {
+ d.mutex.RUnlock()
+ return device, nil
+ }
+ d.mutex.RUnlock()
+
+ // Try to discover all devices and find the specific one
+ devices, err := d.DiscoverDevices(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, device := range devices {
+ if device.Host == host {
+ return device, nil
+ }
+ }
+
+ return nil, fmt.Errorf("device with host %s not found", host)
+}
+
+// GetCachedDevices returns all cached devices that haven't expired
+func (d *DiscoveryService) GetCachedDevices() []*models.DiscoveredDevice {
+ d.cleanupCache()
+ return d.getCachedDevices()
+}
+
+// ClearCache clears the device cache
+func (d *DiscoveryService) ClearCache() {
+ d.mutex.Lock()
+ defer d.mutex.Unlock()
+ d.cache = make(map[string]*models.DiscoveredDevice)
+}
+
+// performDiscovery performs the actual UPnP SSDP discovery
+func (d *DiscoveryService) performDiscovery(ctx context.Context) ([]*models.DiscoveredDevice, error) {
+ // Create UDP connection for multicast
+ conn, err := net.Dial("udp", ssdpAddr)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create UDP connection: %w", err)
+ }
+ defer conn.Close()
+
+ // Send M-SEARCH request
+ msearchRequest := d.buildMSearchRequest()
+ if _, err := conn.Write([]byte(msearchRequest)); err != nil {
+ return nil, fmt.Errorf("failed to send M-SEARCH: %w", err)
+ }
+
+ // Listen for responses
+ devices := make(map[string]*models.DiscoveredDevice)
+
+ // Set read deadline
+ deadline := time.Now().Add(d.timeout)
+ if err := conn.SetReadDeadline(deadline); err != nil {
+ return nil, fmt.Errorf("failed to set read deadline: %w", err)
+ }
+
+ buffer := make([]byte, 4096)
+
+ for time.Now().Before(deadline) {
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ default:
+ n, err := conn.Read(buffer)
+ if err != nil {
+ if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
+ break // Timeout reached, stop reading
+ }
+ return nil, fmt.Errorf("failed to read response: %w", err)
+ }
+
+ device, err := d.parseResponse(string(buffer[:n]))
+ if err != nil {
+ continue // Skip invalid responses
+ }
+
+ if device != nil {
+ devices[device.Host] = device
+ }
+ }
+ }
+
+ // Convert map to slice
+ result := make([]*models.DiscoveredDevice, 0, len(devices))
+ for _, device := range devices {
+ result = append(result, device)
+ }
+
+ return result, nil
+}
+
+// buildMSearchRequest builds the M-SEARCH request for SoundTouch devices
+func (d *DiscoveryService) buildMSearchRequest() string {
+ return fmt.Sprintf(
+ "M-SEARCH * HTTP/1.1\r\n"+
+ "HOST: %s\r\n"+
+ "MAN: \"ssdp:discover\"\r\n"+
+ "ST: %s\r\n"+
+ "MX: %d\r\n"+
+ "\r\n",
+ ssdpAddr,
+ soundTouchURN,
+ int(d.timeout.Seconds()),
+ )
+}
+
+// parseResponse parses UPnP SSDP response and extracts device information
+func (d *DiscoveryService) parseResponse(response string) (*models.DiscoveredDevice, error) {
+ // Try both \r\n and \n line endings
+ var lines []string
+ if strings.Contains(response, "\r\n") {
+ lines = strings.Split(response, "\r\n")
+ } else {
+ lines = strings.Split(response, "\n")
+ }
+
+ // Check if it's a valid HTTP response
+ if len(lines) < 1 || !strings.HasPrefix(lines[0], "HTTP/1.1 200") {
+ return nil, fmt.Errorf("invalid HTTP response")
+ }
+
+ headers := make(map[string]string)
+ for _, line := range lines[1:] {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ break
+ }
+
+ parts := strings.SplitN(line, ":", 2)
+ if len(parts) == 2 {
+ key := strings.TrimSpace(strings.ToLower(parts[0]))
+ value := strings.TrimSpace(parts[1])
+ headers[key] = value
+ }
+ }
+
+ // Check if it's a SoundTouch device
+ st, exists := headers["st"]
+ if !exists {
+ return nil, fmt.Errorf("no ST header found")
+ }
+
+ // Accept both MediaRenderer and any device type for now - we'll validate it's a SoundTouch later
+ if !strings.Contains(strings.ToLower(st), "mediarenderer") && !strings.Contains(strings.ToLower(st), "upnp:rootdevice") {
+ return nil, fmt.Errorf("not a MediaRenderer device")
+ }
+
+ location, exists := headers["location"]
+ if !exists {
+ return nil, fmt.Errorf("no location header found")
+ }
+
+ // Extract device information from location URL
+ device, err := d.parseLocationURL(location)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse location URL: %w", err)
+ }
+
+ // Try to get more device info from the location URL
+ if err := d.enrichDeviceInfo(device, location); err != nil {
+ // Don't fail if we can't get additional info
+ // The basic info from URL parsing should be sufficient
+ }
+
+ return device, nil
+}
+
+// parseLocationURL extracts basic device info from the location URL
+func (d *DiscoveryService) parseLocationURL(location string) (*models.DiscoveredDevice, error) {
+ // Parse the URL to extract host and port
+ re := regexp.MustCompile(`http://([^:]+):(\d+)`)
+ matches := re.FindStringSubmatch(location)
+
+ if len(matches) < 2 {
+ return nil, fmt.Errorf("invalid location URL format")
+ }
+
+ host := matches[1]
+ port := 8090 // Default SoundTouch port
+
+ device := &models.DiscoveredDevice{
+ Host: host,
+ Port: port,
+ Location: location,
+ LastSeen: time.Now(),
+ Name: fmt.Sprintf("SoundTouch-%s", host), // Default name
+ }
+
+ return device, nil
+}
+
+// enrichDeviceInfo tries to get additional device information from the device description
+func (d *DiscoveryService) enrichDeviceInfo(device *models.DiscoveredDevice, location string) error {
+ client := &http.Client{
+ Timeout: 5 * time.Second,
+ }
+
+ resp, err := client.Get(location)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ // For now, we'll keep it simple and not parse the full UPnP device description
+ // This can be enhanced later to extract more detailed device information
+ return nil
+}
+
+// updateCache updates the device cache with discovered devices
+func (d *DiscoveryService) updateCache(devices []*models.DiscoveredDevice) {
+ d.mutex.Lock()
+ defer d.mutex.Unlock()
+
+ for _, device := range devices {
+ d.cache[device.Host] = device
+ }
+}
+
+// getCachedDevices returns all valid cached devices (internal method)
+func (d *DiscoveryService) getCachedDevices() []*models.DiscoveredDevice {
+ d.mutex.RLock()
+ defer d.mutex.RUnlock()
+
+ devices := make([]*models.DiscoveredDevice, 0, len(d.cache))
+ for _, device := range d.cache {
+ if time.Since(device.LastSeen) < d.cacheTTL {
+ devices = append(devices, device)
+ }
+ }
+
+ return devices
+}
+
+// cleanupCache removes expired devices from cache
+func (d *DiscoveryService) cleanupCache() {
+ d.mutex.Lock()
+ defer d.mutex.Unlock()
+
+ for host, device := range d.cache {
+ if time.Since(device.LastSeen) >= d.cacheTTL {
+ delete(d.cache, host)
+ }
+ }
+}
+
+// getConfiguredDevices returns devices from configuration
+func (d *DiscoveryService) getConfiguredDevices() []*models.DiscoveredDevice {
+ return d.config.GetPreferredDevicesAsDiscovered()
+}
+
+// mergeDevices merges two device lists, avoiding duplicates based on host
+func (d *DiscoveryService) mergeDevices(existing, new []*models.DiscoveredDevice) []*models.DiscoveredDevice {
+ hostSet := make(map[string]bool)
+ result := make([]*models.DiscoveredDevice, 0, len(existing)+len(new))
+
+ // Add existing devices
+ for _, device := range existing {
+ if !hostSet[device.Host] {
+ result = append(result, device)
+ hostSet[device.Host] = true
+ }
+ }
+
+ // Add new devices if not already present
+ for _, device := range new {
+ if !hostSet[device.Host] {
+ result = append(result, device)
+ hostSet[device.Host] = true
+ }
+ }
+
+ return result
+}
diff --git a/pkg/discovery/upnp_test.go b/pkg/discovery/upnp_test.go
new file mode 100644
index 0000000..5c006c1
--- /dev/null
+++ b/pkg/discovery/upnp_test.go
@@ -0,0 +1,500 @@
+package discovery
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/user_account/bose-soundtouch/pkg/config"
+ "github.com/user_account/bose-soundtouch/pkg/models"
+)
+
+func TestNewDiscoveryService(t *testing.T) {
+ timeout := 5 * time.Second
+ service := NewDiscoveryService(timeout)
+
+ if service.timeout != timeout {
+ t.Errorf("Expected timeout %v, got %v", timeout, service.timeout)
+ }
+
+ if service.cacheTTL != defaultCacheTTL {
+ t.Errorf("Expected cacheTTL %v, got %v", defaultCacheTTL, service.cacheTTL)
+ }
+
+ if service.cache == nil {
+ t.Error("Expected cache to be initialized")
+ }
+}
+
+func TestNewDiscoveryServiceWithDefaultTimeout(t *testing.T) {
+ service := NewDiscoveryService(0)
+
+ if service.timeout != defaultTimeout {
+ t.Errorf("Expected default timeout %v, got %v", defaultTimeout, service.timeout)
+ }
+}
+
+func TestBuildMSearchRequest(t *testing.T) {
+ service := NewDiscoveryService(5 * time.Second)
+ request := service.buildMSearchRequest()
+
+ expectedLines := []string{
+ "M-SEARCH * HTTP/1.1",
+ "HOST: 239.255.255.250:1900",
+ "MAN: \"ssdp:discover\"",
+ "ST: urn:schemas-upnp-org:device:MediaRenderer:1",
+ "MX: 5",
+ }
+
+ for _, expectedLine := range expectedLines {
+ if !contains(request, expectedLine) {
+ t.Errorf("Expected M-SEARCH request to contain '%s'", expectedLine)
+ }
+ }
+
+ // Check that request ends with double CRLF
+ if !contains(request, "\r\n\r\n") {
+ t.Error("Expected M-SEARCH request to end with double CRLF")
+ }
+}
+
+func TestParseLocationURL_Valid(t *testing.T) {
+ service := NewDiscoveryService(5 * time.Second)
+ location := "http://192.168.1.100:8090/device.xml"
+
+ device, err := service.parseLocationURL(location)
+ if err != nil {
+ t.Fatalf("Expected no error, got: %v", err)
+ }
+
+ if device.Host != "192.168.1.100" {
+ t.Errorf("Expected host '192.168.1.100', got '%s'", device.Host)
+ }
+
+ if device.Port != 8090 {
+ 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.Name == "" {
+ t.Error("Expected device name to be set")
+ }
+
+ expectedName := "SoundTouch-192.168.1.100"
+ if device.Name != expectedName {
+ t.Errorf("Expected name '%s', got '%s'", expectedName, device.Name)
+ }
+}
+
+func TestParseLocationURL_Invalid(t *testing.T) {
+ service := NewDiscoveryService(5 * time.Second)
+
+ invalidURLs := []string{
+ "not-a-url",
+ "ftp://192.168.1.100/device.xml",
+ "http://invalid-format",
+ "",
+ }
+
+ for _, url := range invalidURLs {
+ _, err := service.parseLocationURL(url)
+ if err == nil {
+ t.Errorf("Expected error for invalid URL '%s', got nil", url)
+ }
+ }
+}
+
+func TestParseResponse_ValidMediaRenderer(t *testing.T) {
+ service := NewDiscoveryService(5 * time.Second)
+
+ validResponse := `HTTP/1.1 200 OK
+Cache-Control: max-age=1800
+Date: Mon, 22 Jun 1998 09:55:21 GMT
+EXT:
+Location: http://192.168.1.100:8090/device.xml
+Server: Linux/3.14.0 UPnP/1.0 Bose-SoundTouch/1.0
+ST: urn:schemas-upnp-org:device:MediaRenderer:1
+USN: uuid:12345678-1234-5678-9012-123456789012::urn:schemas-upnp-org:device:MediaRenderer:1
+
+`
+
+ device, err := service.parseResponse(validResponse)
+ if err != nil {
+ t.Fatalf("Expected no error, got: %v", err)
+ }
+
+ if device == nil {
+ t.Fatal("Expected device, got nil")
+ }
+
+ if device.Host != "192.168.1.100" {
+ 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)
+ }
+}
+
+func TestParseResponse_NotMediaRenderer(t *testing.T) {
+ service := NewDiscoveryService(5 * time.Second)
+
+ nonMediaRendererResponse := `HTTP/1.1 200 OK
+Cache-Control: max-age=1800
+Date: Mon, 22 Jun 1998 09:55:21 GMT
+EXT:
+Location: http://192.168.1.100:8090/device.xml
+Server: Linux/3.14.0 UPnP/1.0 SomeDevice/1.0
+ST: urn:schemas-upnp-org:device:SomeOtherDevice:1
+USN: uuid:12345678-1234-5678-9012-123456789012::urn:schemas-upnp-org:device:SomeOtherDevice:1
+
+`
+
+ device, err := service.parseResponse(nonMediaRendererResponse)
+ if err == nil {
+ t.Error("Expected error for non-MediaRenderer device, got nil")
+ }
+
+ if device != nil {
+ t.Error("Expected nil device for non-MediaRenderer, got device")
+ }
+
+ expectedError := "not a MediaRenderer device"
+ if !contains(err.Error(), expectedError) {
+ t.Errorf("Expected error to contain '%s', got '%s'", expectedError, err.Error())
+ }
+}
+
+func TestParseResponse_InvalidHTTP(t *testing.T) {
+ service := NewDiscoveryService(5 * time.Second)
+
+ invalidResponses := []string{
+ "not http response",
+ "HTTP/1.1 404 Not Found\r\n\r\n",
+ "",
+ "HTTP/1.1 200 OK\r\nLocation: invalid-location\r\nST: urn:schemas-upnp-org:device:MediaRenderer:1\r\n\r\n",
+ }
+
+ for _, response := range invalidResponses {
+ device, err := service.parseResponse(response)
+ if err == nil && response != "" {
+ t.Errorf("Expected error for invalid response, got nil for: %s", response)
+ }
+ if device != nil {
+ t.Errorf("Expected nil device for invalid response, got device for: %s", response)
+ }
+ }
+}
+
+func TestParseResponse_NoLocation(t *testing.T) {
+ service := NewDiscoveryService(5 * time.Second)
+
+ responseWithoutLocation := `HTTP/1.1 200 OK
+Cache-Control: max-age=1800
+Date: Mon, 22 Jun 1998 09:55:21 GMT
+ST: urn:schemas-upnp-org:device:MediaRenderer:1
+
+`
+
+ device, err := service.parseResponse(responseWithoutLocation)
+ if err == nil {
+ t.Error("Expected error for response without location, got nil")
+ }
+
+ if device != nil {
+ t.Error("Expected nil device for response without location")
+ }
+
+ expectedError := "no location header found"
+ if !contains(err.Error(), expectedError) {
+ t.Errorf("Expected error to contain '%s', got '%s'", expectedError, err.Error())
+ }
+}
+
+func TestCacheOperations(t *testing.T) {
+ service := NewDiscoveryService(5 * time.Second)
+
+ // Test empty cache
+ devices := service.GetCachedDevices()
+ if len(devices) != 0 {
+ t.Errorf("Expected empty cache, got %d devices", len(devices))
+ }
+
+ // Add devices to cache
+ testDevices := []*models.DiscoveredDevice{
+ {
+ Host: "192.168.1.100",
+ Port: 8090,
+ Name: "Device 1",
+ LastSeen: time.Now(),
+ },
+ {
+ Host: "192.168.1.101",
+ Port: 8090,
+ Name: "Device 2",
+ LastSeen: time.Now(),
+ },
+ }
+
+ service.updateCache(testDevices)
+
+ // Test cached devices retrieval
+ cachedDevices := service.GetCachedDevices()
+ if len(cachedDevices) != 2 {
+ t.Errorf("Expected 2 cached devices, got %d", len(cachedDevices))
+ }
+
+ // Test cache clear
+ service.ClearCache()
+ devices = service.GetCachedDevices()
+ if len(devices) != 0 {
+ t.Errorf("Expected empty cache after clear, got %d devices", len(devices))
+ }
+}
+
+func TestCacheExpiration(t *testing.T) {
+ service := NewDiscoveryService(5 * time.Second)
+ service.cacheTTL = 100 * time.Millisecond // Short TTL for testing
+
+ // Add device with old timestamp
+ expiredDevice := &models.DiscoveredDevice{
+ Host: "192.168.1.100",
+ Port: 8090,
+ Name: "Expired Device",
+ LastSeen: time.Now().Add(-200 * time.Millisecond), // Expired
+ }
+
+ // Add device with recent timestamp
+ freshDevice := &models.DiscoveredDevice{
+ Host: "192.168.1.101",
+ Port: 8090,
+ Name: "Fresh Device",
+ LastSeen: time.Now(), // Fresh
+ }
+
+ service.updateCache([]*models.DiscoveredDevice{expiredDevice, freshDevice})
+
+ // Wait a bit to ensure expiration
+ time.Sleep(50 * time.Millisecond)
+
+ // Test that expired device is filtered out
+ devices := service.GetCachedDevices()
+ if len(devices) != 1 {
+ t.Errorf("Expected 1 non-expired device, got %d", len(devices))
+ }
+
+ if len(devices) > 0 && devices[0].Host != "192.168.1.101" {
+ t.Errorf("Expected fresh device, got %s", devices[0].Host)
+ }
+}
+
+func TestDiscoverDevices_UseCache(t *testing.T) {
+ service := NewDiscoveryService(5 * time.Second)
+
+ // Add fresh device to cache
+ freshDevice := &models.DiscoveredDevice{
+ Host: "192.168.1.100",
+ Port: 8090,
+ Name: "Cached Device",
+ LastSeen: time.Now(),
+ }
+
+ service.updateCache([]*models.DiscoveredDevice{freshDevice})
+
+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
+ defer cancel()
+
+ // Should return cached device without network discovery
+ devices, err := service.DiscoverDevices(ctx)
+ if err != nil {
+ t.Fatalf("Expected no error, got: %v", err)
+ }
+
+ if len(devices) != 1 {
+ t.Errorf("Expected 1 cached device, got %d", len(devices))
+ }
+
+ if len(devices) > 0 && devices[0].Host != "192.168.1.100" {
+ t.Errorf("Expected cached device host, got %s", devices[0].Host)
+ }
+}
+
+func TestDiscoverDevice_FromCache(t *testing.T) {
+ service := NewDiscoveryService(5 * time.Second)
+
+ // Add device to cache
+ device := &models.DiscoveredDevice{
+ Host: "192.168.1.100",
+ Port: 8090,
+ Name: "Test Device",
+ LastSeen: time.Now(),
+ }
+
+ service.updateCache([]*models.DiscoveredDevice{device})
+
+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
+ defer cancel()
+
+ // Discover specific device from cache
+ foundDevice, err := service.DiscoverDevice(ctx, "192.168.1.100")
+ if err != nil {
+ t.Fatalf("Expected no error, got: %v", err)
+ }
+
+ if foundDevice.Host != "192.168.1.100" {
+ t.Errorf("Expected host '192.168.1.100', got '%s'", foundDevice.Host)
+ }
+
+ if foundDevice.Name != "Test Device" {
+ t.Errorf("Expected name 'Test Device', got '%s'", foundDevice.Name)
+ }
+}
+
+func TestDiscoverDevice_NotFound(t *testing.T) {
+ service := NewDiscoveryService(100 * time.Millisecond) // Short timeout
+
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+
+ // Try to discover non-existent device
+ _, err := service.DiscoverDevice(ctx, "192.168.1.999")
+ if err == nil {
+ t.Error("Expected error for non-existent device, got nil")
+ }
+
+ expectedError := "device with host 192.168.1.999 not found"
+ if !contains(err.Error(), expectedError) {
+ t.Errorf("Expected error to contain '%s', got '%s'", expectedError, err.Error())
+ }
+}
+
+func TestNewDiscoveryServiceWithConfig(t *testing.T) {
+ cfg := &config.Config{
+ DiscoveryTimeout: 15 * time.Second,
+ CacheTTL: 60 * time.Second,
+ UPnPEnabled: false,
+ PreferredDevices: []config.DeviceConfig{
+ {Name: "Test Device", Host: "192.168.1.100", Port: 8090},
+ },
+ }
+
+ service := NewDiscoveryServiceWithConfig(cfg)
+
+ if service.timeout != 15*time.Second {
+ t.Errorf("Expected timeout 15s, got %v", service.timeout)
+ }
+
+ if service.cacheTTL != 60*time.Second {
+ t.Errorf("Expected cache TTL 60s, got %v", service.cacheTTL)
+ }
+
+ if service.config.UPnPEnabled {
+ t.Error("Expected UPnP to be disabled")
+ }
+}
+
+func TestGetConfiguredDevices(t *testing.T) {
+ cfg := &config.Config{
+ PreferredDevices: []config.DeviceConfig{
+ {Name: "Living Room", Host: "192.168.1.100", Port: 8090},
+ {Name: "Kitchen", Host: "192.168.1.101", Port: 8091},
+ },
+ }
+
+ service := NewDiscoveryServiceWithConfig(cfg)
+ devices := service.getConfiguredDevices()
+
+ if len(devices) != 2 {
+ t.Errorf("Expected 2 configured devices, got %d", len(devices))
+ }
+
+ if devices[0].Name != "Living Room" {
+ t.Errorf("Expected first device name 'Living Room', got '%s'", devices[0].Name)
+ }
+
+ if devices[0].Host != "192.168.1.100" {
+ t.Errorf("Expected first device host '192.168.1.100', got '%s'", devices[0].Host)
+ }
+
+ if devices[1].Port != 8091 {
+ t.Errorf("Expected second device port 8091, got %d", devices[1].Port)
+ }
+}
+
+func TestMergeDevices(t *testing.T) {
+ service := NewDiscoveryService(5 * time.Second)
+
+ existing := []*models.DiscoveredDevice{
+ {Host: "192.168.1.100", Name: "Device 1", Port: 8090},
+ {Host: "192.168.1.101", Name: "Device 2", Port: 8090},
+ }
+
+ new := []*models.DiscoveredDevice{
+ {Host: "192.168.1.101", Name: "Duplicate", Port: 8090}, // Duplicate
+ {Host: "192.168.1.102", Name: "Device 3", Port: 8090}, // New
+ }
+
+ merged := service.mergeDevices(existing, new)
+
+ if len(merged) != 3 {
+ t.Errorf("Expected 3 merged devices, got %d", len(merged))
+ }
+
+ // Check that duplicates are avoided
+ hosts := make(map[string]int)
+ for _, device := range merged {
+ hosts[device.Host]++
+ }
+
+ for host, count := range hosts {
+ if count > 1 {
+ t.Errorf("Host %s appears %d times (should be unique)", host, count)
+ }
+ }
+
+ // Check that all unique hosts are present
+ expectedHosts := []string{"192.168.1.100", "192.168.1.101", "192.168.1.102"}
+ for _, expectedHost := range expectedHosts {
+ if _, exists := hosts[expectedHost]; !exists {
+ t.Errorf("Expected host %s not found in merged devices", expectedHost)
+ }
+ }
+}
+
+func TestDiscoverDevices_ConfiguredOnly(t *testing.T) {
+ cfg := &config.Config{
+ UPnPEnabled: false, // Disable UPnP
+ PreferredDevices: []config.DeviceConfig{
+ {Name: "Test Device", Host: "192.168.1.100", Port: 8090},
+ },
+ }
+
+ service := NewDiscoveryServiceWithConfig(cfg)
+ ctx := context.Background()
+
+ devices, err := service.DiscoverDevices(ctx)
+ if err != nil {
+ t.Fatalf("Expected no error, got: %v", err)
+ }
+
+ if len(devices) != 1 {
+ t.Errorf("Expected 1 configured device, got %d", len(devices))
+ }
+
+ if devices[0].Name != "Test Device" {
+ t.Errorf("Expected device name 'Test Device', got '%s'", devices[0].Name)
+ }
+
+ if devices[0].Host != "192.168.1.100" {
+ t.Errorf("Expected device host '192.168.1.100', got '%s'", devices[0].Host)
+ }
+}
+
+// Helper function to check if string contains substring
+func contains(s, substr string) bool {
+ return strings.Contains(s, substr)
+}
diff --git a/pkg/models/device.go b/pkg/models/device.go
new file mode 100644
index 0000000..a690cdd
--- /dev/null
+++ b/pkg/models/device.go
@@ -0,0 +1,65 @@
+package models
+
+import (
+ "encoding/xml"
+ "time"
+)
+
+// DeviceInfo represents the response from GET /info endpoint
+type DeviceInfo struct {
+ XMLName xml.Name `xml:"info"`
+ DeviceID string `xml:"deviceID,attr"`
+ Name string `xml:"name"`
+ Type string `xml:"type"`
+ MargeAccountUUID string `xml:"margeAccountUUID"`
+ Components []Component `xml:"components>component"`
+ MargeURL string `xml:"margeURL"`
+ NetworkInfo []NetworkInfo `xml:"networkInfo"`
+ ModuleType string `xml:"moduleType"`
+ Variant string `xml:"variant"`
+ VariantMode string `xml:"variantMode"`
+ CountryCode string `xml:"countryCode"`
+ RegionCode string `xml:"regionCode"`
+}
+
+// Component represents a device component
+type Component struct {
+ ComponentCategory string `xml:"componentCategory"`
+ SoftwareVersion string `xml:"softwareVersion"`
+ SerialNumber string `xml:"serialNumber"`
+}
+
+// NetworkInfo represents network information for the device
+type NetworkInfo struct {
+ Type string `xml:"type,attr"`
+ MacAddress string `xml:"macAddress"`
+ IPAddress string `xml:"ipAddress"`
+}
+
+// XMLResponse is a generic wrapper for API responses
+type XMLResponse struct {
+ XMLName xml.Name
+ Error *APIError `xml:"error,omitempty"`
+}
+
+// APIError represents an error response from the API
+type APIError struct {
+ Code int `xml:"code,attr"`
+ Message string `xml:",chardata"`
+}
+
+// Error implements the error interface
+func (e *APIError) Error() string {
+ return e.Message
+}
+
+// DiscoveredDevice represents a device found through UPnP 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"`
+}