Bose SoundTouch Toolkit

Documentation for controlling and preserving Bose SoundTouch devices

View the Project on GitHub gesellix/Bose-SoundTouch

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:build !wasm
// +build !wasm

// Native Go implementation

//go:build wasm
// +build wasm

// WASM-specific implementation

Key Aspects:

Build System for Multi-Target

# 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

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:

func (c *Client) SendKey(key models.Key) error {
    keyXML := fmt.Sprintf(`<key state="press" sender="GoClient">%s</key>`, 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

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

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: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

// 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

// 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

// 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

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

// 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

// 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

// 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

# test/docker/Dockerfile
FROM golang:1.25-alpine

WORKDIR /app
COPY . .

# Install test dependencies
RUN go mod download

# Run tests
CMD ["go", "test", "-v", "./..."]
# 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
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

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

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

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

// 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

// 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

// 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

2. API Client Best Practices

3. Developer Experience

4. Production Ready

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.