Documentation for controlling and preserving Bose SoundTouch devices
┌─────────────────────────────────────────────────────────────┐
│ SoundTouch Service │
├─────────────────────────────────────────────────────────────┤
│ HTTP Router & Middleware │
│ ├── Mirror Middleware (Enhanced) │
│ ├── Recorder Middleware │
│ ├── Disparity Detection │
│ └── Health Check Middleware │
├─────────────────────────────────────────────────────────────┤
│ Service Layer │
│ ├── Account Manager ├── Lifecycle Manager │
│ ├── Migration Controller ├── Data Source Router │
│ ├── Event Processor ├── Health Monitor │
│ └── Export Manager └── Analytics Engine │
├─────────────────────────────────────────────────────────────┤
│ Data Layer │
│ ├── Enhanced DataStore ├── Event Store │
│ ├── Mirror Cache ├── Metrics Store │
│ └── Configuration Store └── Session Store │
├─────────────────────────────────────────────────────────────┤
│ External Integrations │
│ ├── Bose Services (Mirror) ├── Device Discovery │
│ ├── BMX/TuneIn Services └── SSH/Setup Manager │
└─────────────────────────────────────────────────────────────┘
pkg/service/
├── account/ # Account management
│ ├── manager.go
│ ├── persistence.go
│ └── validation.go
├── lifecycle/ # Device lifecycle management
│ ├── manager.go
│ ├── states.go
│ ├── transitions.go
│ └── events.go
├── migration/ # Enhanced migration (extends existing)
│ ├── controller.go
│ ├── strategies.go
│ └── progress.go
├── datasource/ # Data source routing
│ ├── router.go
│ ├── preferences.go
│ └── fallback.go
├── events/ # Event processing system
│ ├── processor.go
│ ├── queue.go
│ └── storage.go
├── mirror/ # Enhanced mirroring (extends existing)
│ ├── disparity.go
│ ├── analyzer.go
│ └── logger.go
├── health/ # System monitoring
│ ├── monitor.go
│ ├── metrics.go
│ └── alerts.go
└── export/ # Data export and backup
├── exporter.go
├── formats.go
└── backup.go
type Account struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Status AccountStatus `json:"status"`
DeviceCount int `json:"device_count"`
MigrationInfo *MigrationInfo `json:"migration_info,omitempty"`
BoseAccountID string `json:"bose_account_id,omitempty"`
DataSources DataSourceConfig `json:"data_sources"`
Settings AccountSettings `json:"settings"`
}
type AccountStatus string
const (
AccountStatusActive AccountStatus = "active"
AccountStatusMigrating AccountStatus = "migrating"
AccountStatusSuspended AccountStatus = "suspended"
AccountStatusArchived AccountStatus = "archived"
)
type MigrationInfo struct {
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
DevicesMigrated int `json:"devices_migrated"`
DevicesPending int `json:"devices_pending"`
MirrorActive bool `json:"mirror_active"`
Strategy string `json:"strategy"`
RollbackData string `json:"rollback_data,omitempty"`
}
type DataSourceConfig struct {
Local bool `json:"local"`
BoseMirror bool `json:"bose_mirror"`
Primary string `json:"primary"` // "local" or "bose"
}
type AccountSettings struct {
AutoMigration bool `json:"auto_migration"`
MirrorEndpoints []string `json:"mirror_endpoints"`
RetentionDays int `json:"retention_days"`
}
type DeviceLifecycle struct {
DeviceID string `json:"device_id"`
AccountID string `json:"account_id"`
State DeviceState `json:"state"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
StateHistory []StateTransition `json:"state_history"`
Metadata DeviceMetadata `json:"metadata"`
DataSources DataSourceConfig `json:"data_sources"`
Migration *DeviceMigration `json:"migration,omitempty"`
Health DeviceHealth `json:"health"`
}
type DeviceState string
const (
DeviceStateUnregistered DeviceState = "unregistered"
DeviceStateDiscovered DeviceState = "discovered"
DeviceStateRegistering DeviceState = "registering"
DeviceStateActive DeviceState = "active"
DeviceStateMigrating DeviceState = "migrating"
DeviceStateOffline DeviceState = "offline"
DeviceStateError DeviceState = "error"
DeviceStateRetired DeviceState = "retired"
)
type StateTransition struct {
From DeviceState `json:"from"`
To DeviceState `json:"to"`
Timestamp time.Time `json:"timestamp"`
Reason string `json:"reason"`
Source string `json:"source"`
Context map[string]interface{} `json:"context,omitempty"`
}
type DeviceMetadata struct {
Name string `json:"name"`
Type string `json:"type"`
SerialNumber string `json:"serial_number"`
FirmwareVersion string `json:"firmware_version"`
MACAddress string `json:"mac_address"`
IPAddress string `json:"ip_address"`
LastSeen time.Time `json:"last_seen"`
IsLegacyID bool `json:"is_legacy_id"`
Capabilities []string `json:"capabilities,omitempty"`
}
type DeviceMigration struct {
FromBoseAccount string `json:"from_bose_account,omitempty"`
MigratedAt *time.Time `json:"migrated_at,omitempty"`
Method string `json:"method"`
RollbackAvailable bool `json:"rollback_available"`
DataPreserved []string `json:"data_preserved"`
}
type DeviceHealth struct {
Status string `json:"status"` // "healthy", "warning", "error"
LastCheck time.Time `json:"last_check"`
ResponseTime int `json:"response_time_ms"`
Connectivity string `json:"connectivity"` // "online", "offline", "intermittent"
ErrorCount int `json:"error_count"`
LastError string `json:"last_error,omitempty"`
}
type DeviceEvent struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
AccountID string `json:"account_id"`
Type DeviceEventType `json:"type"`
Data map[string]interface{} `json:"data"`
Timestamp time.Time `json:"timestamp"`
Source EventSource `json:"source"`
Processed bool `json:"processed"`
Context EventContext `json:"context"`
}
type DeviceEventType string
const (
EventTypeNowPlaying DeviceEventType = "now_playing"
EventTypePresetChanged DeviceEventType = "preset_changed"
EventTypeVolumeChanged DeviceEventType = "volume_changed"
EventTypeSourceChanged DeviceEventType = "source_changed"
EventTypeDeviceOnline DeviceEventType = "device_online"
EventTypeDeviceOffline DeviceEventType = "device_offline"
EventTypeZoneChanged DeviceEventType = "zone_changed"
EventTypeDisparityFound DeviceEventType = "disparity_found"
EventTypeMigrationStart DeviceEventType = "migration_start"
EventTypeMigrationEnd DeviceEventType = "migration_end"
EventTypeHealthCheck DeviceEventType = "health_check"
EventTypeErrorOccurred DeviceEventType = "error_occurred"
)
type EventSource string
const (
EventSourceWebSocket EventSource = "websocket"
EventSourceDiscovery EventSource = "discovery"
EventSourceMirror EventSource = "mirror"
EventSourceSystem EventSource = "system"
EventSourceAPI EventSource = "api"
EventSourceUser EventSource = "user"
)
type EventContext struct {
RequestID string `json:"request_id,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Additional map[string]interface{} `json:"additional,omitempty"`
}
type Disparity struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
DeviceID string `json:"device_id"`
AccountID string `json:"account_id"`
Endpoint string `json:"endpoint"`
Type DisparityType `json:"type"`
Severity DisparitySeverity `json:"severity"`
LocalHash string `json:"local_hash"`
UpstreamHash string `json:"upstream_hash"`
Details DisparityDetails `json:"details"`
Context map[string]interface{} `json:"context"`
Resolved bool `json:"resolved"`
}
type DisparityType string
const (
DisparityTypeContentMismatch DisparityType = "content_mismatch"
DisparityTypeStructureDiff DisparityType = "structure_diff"
DisparityTypeTimestampFormat DisparityType = "timestamp_format"
DisparityTypeFieldMissing DisparityType = "field_missing"
DisparityTypeValueMismatch DisparityType = "value_mismatch"
DisparityTypeCountMismatch DisparityType = "count_mismatch"
)
type DisparitySeverity string
const (
DisparitySeverityLow DisparitySeverity = "low"
DisparitySeverityMedium DisparitySeverity = "medium"
DisparitySeverityHigh DisparitySeverity = "high"
DisparitySeverityCritical DisparitySeverity = "critical"
)
type DisparityDetails struct {
FieldPath string `json:"field_path"`
LocalValue interface{} `json:"local_value"`
UpstreamValue interface{} `json:"upstream_value"`
Description string `json:"description"`
}
POST /api/v1/accounts
Content-Type: application/json
{
"name": "User Account",
"email": "user@example.com",
"settings": {
"auto_migration": false,
"retention_days": 30
}
}
Response: 201 Created
{
"id": "acc_12345",
"name": "User Account",
"email": "user@example.com",
"created_at": "2024-01-20T10:00:00Z",
"status": "active",
"device_count": 0
}
GET /api/v1/accounts/{account_id}
Response: 200 OK
{
"id": "acc_12345",
"name": "User Account",
"status": "active",
"device_count": 2,
"migration_info": {
"started_at": "2024-01-18T09:00:00Z",
"devices_migrated": 1,
"devices_pending": 1,
"mirror_active": true
},
"data_sources": {
"local": true,
"bose_mirror": true,
"primary": "bose"
}
}
GET /api/v1/accounts?status=active&limit=10&offset=0
Response: 200 OK
{
"accounts": [...],
"total": 5,
"limit": 10,
"offset": 0
}
POST /api/v1/accounts/{account_id}/devices
Content-Type: application/json
{
"device_id": "A81B6A536A98",
"name": "Living Room Speaker",
"registration_type": "fresh"
}
Response: 201 Created
{
"device_id": "A81B6A536A98",
"account_id": "acc_12345",
"state": "registering",
"created_at": "2024-01-20T10:00:00Z"
}
GET /api/v1/accounts/{account_id}/devices/{device_id}/state
Response: 200 OK
{
"device_id": "A81B6A536A98",
"account_id": "acc_12345",
"state": "active",
"metadata": {
"name": "Living Room Speaker",
"type": "SoundTouch 30",
"last_seen": "2024-01-20T15:30:00Z"
},
"health": {
"status": "healthy",
"connectivity": "online",
"response_time": 45
}
}
POST /api/v1/accounts/{account_id}/devices/{device_id}/migrate
Content-Type: application/json
{
"from_bose_account": "bose-acc-xyz",
"preserve_data": true,
"method": "gradual"
}
Response: 202 Accepted
{
"migration_id": "mig_67890",
"status": "started",
"estimated_completion": "2024-01-20T11:00:00Z"
}
GET /api/v1/accounts/{account_id}/devices/{device_id}/events?since=2024-01-20T00:00:00Z&type=now_playing&limit=50
Response: 200 OK
{
"events": [
{
"id": "evt_12345",
"type": "now_playing",
"timestamp": "2024-01-20T15:30:00Z",
"data": {
"source": "SPOTIFY",
"track": "Song Name",
"artist": "Artist Name"
}
}
],
"total": 125,
"has_more": true
}
GET /api/v1/accounts/{account_id}/devices/{device_id}/events/stream
Accept: text/event-stream
Response: 200 OK
Content-Type: text/event-stream
data: {"id":"evt_12346","type":"volume_changed","timestamp":"2024-01-20T15:31:00Z","data":{"volume":50}}
data: {"id":"evt_12347","type":"now_playing","timestamp":"2024-01-20T15:32:00Z","data":{"source":"TUNEIN"}}
GET /api/v1/system/health
Response: 200 OK
{
"status": "healthy",
"timestamp": "2024-01-20T15:30:00Z",
"services": {
"account_manager": "healthy",
"lifecycle_manager": "healthy",
"event_processor": "healthy",
"mirror_service": "warning"
},
"statistics": {
"total_accounts": 5,
"total_devices": 12,
"active_devices": 10,
"events_processed_24h": 1547
}
}
GET /api/v1/system/disparities?since=2024-01-20T00:00:00Z&severity=high
Response: 200 OK
{
"disparities": [
{
"id": "disp_12345",
"timestamp": "2024-01-20T14:30:00Z",
"endpoint": "/v1/presets",
"type": "count_mismatch",
"severity": "high",
"details": {
"field_path": "preset_count",
"local_value": 5,
"upstream_value": 4
}
}
],
"summary": {
"total": 15,
"by_severity": {
"high": 2,
"medium": 8,
"low": 5
}
}
}
{
"version": "1.0",
"id": "acc_12345",
"name": "User Account",
"email": "user@example.com",
"created_at": "2024-01-20T10:00:00Z",
"updated_at": "2024-01-20T15:30:00Z",
"status": "active",
"device_count": 2,
"migration_info": {
"started_at": "2024-01-18T09:00:00Z",
"devices_migrated": 1,
"devices_pending": 1,
"mirror_active": true,
"strategy": "gradual"
},
"bose_account_id": "bose-original-id",
"data_sources": {
"local": true,
"bose_mirror": true,
"primary": "bose"
},
"settings": {
"auto_migration": false,
"mirror_endpoints": ["/v1/presets", "/v1/recents"],
"retention_days": 30
}
}
{
"version": "1.0",
"device_id": "A81B6A536A98",
"account_id": "acc_12345",
"state": "active",
"created_at": "2024-01-20T10:00:00Z",
"updated_at": "2024-01-20T15:30:00Z",
"state_history": [
{
"from": "unregistered",
"to": "discovered",
"timestamp": "2024-01-20T10:00:00Z",
"reason": "mdns_discovery",
"source": "discovery",
"context": {
"ip_address": "192.168.1.100",
"discovery_method": "mdns"
}
},
{
"from": "discovered",
"to": "active",
"timestamp": "2024-01-20T10:05:00Z",
"reason": "registration_complete",
"source": "system"
}
],
"metadata": {
"name": "Living Room Speaker",
"type": "SoundTouch 30",
"serial_number": "I6332527703739342000020",
"firmware_version": "4.8.1.25341.2677643.1597353330",
"mac_address": "A8:1B:6A:53:6A:98",
"ip_address": "192.168.1.100",
"last_seen": "2024-01-20T15:30:00Z",
"is_legacy_id": false,
"capabilities": ["multiroom", "bluetooth", "aux"]
},
"data_sources": {
"presets": "local",
"recents": "mirror_primary",
"sources": "local"
},
"migration": {
"from_bose_account": "bose-acc-xyz",
"migrated_at": "2024-01-18T14:30:00Z",
"method": "gradual",
"rollback_available": true,
"data_preserved": ["presets", "recents", "sources"]
},
"health": {
"status": "healthy",
"last_check": "2024-01-20T15:30:00Z",
"response_time": 45,
"connectivity": "online",
"error_count": 0
}
}
# SoundTouch Service Event Log - Device A81B6A536A98
# Format: TIMESTAMP|EVENT_ID|EVENT_TYPE|SOURCE|DATA_JSON
# Version: 1.0
2024-01-20T15:30:00.123Z|evt_12345|now_playing|websocket|{"source":"SPOTIFY","track":"Song Name","artist":"Artist Name","album":"Album Name"}
2024-01-20T15:30:30.456Z|evt_12346|volume_changed|websocket|{"volume":45,"muted":false,"previous_volume":40}
2024-01-20T15:31:00.789Z|evt_12347|preset_selected|websocket|{"preset":1,"source":"SPOTIFY","location":"spotify:track:123abc"}
2024-01-20T15:31:15.012Z|evt_12348|disparity_detected|mirror|{"endpoint":"/v1/presets","local_hash":"abc123","upstream_hash":"def456","severity":"medium"}
2024-01-20T15:32:00.345Z|evt_12349|health_check|system|{"response_time":42,"status":"healthy","connectivity":"online"}
# SoundTouch Service Disparity Log
# Format: TIMESTAMP|DISPARITY_ID|DEVICE_ID|ACCOUNT_ID|ENDPOINT|TYPE|SEVERITY|DETAILS_JSON
# Version: 1.0
2024-01-20T15:31:15.012Z|disp_12345|A81B6A536A98|acc_12345|/v1/presets|count_mismatch|medium|{"field_path":"preset_count","local_value":5,"upstream_value":4,"description":"Local has one additional preset"}
2024-01-20T15:32:45.678Z|disp_12346|A81B6A536A98|acc_12345|/v1/recents|timestamp_format|low|{"field_path":"recent[0].utc_time","local_value":"2024-01-20T15:30:00Z","upstream_value":"1705761000","description":"Timestamp format difference"}
2024-01-20T15:35:20.901Z|disp_12347|B92C7B647B09|acc_12345|/v1/account/full|structure_diff|high|{"field_path":"device[1].ip_address","local_value":"present","upstream_value":"missing","description":"IP address field missing in upstream response"}
Unregistered → Discovered (via discovery)
Discovered → Registering (via user action/auto-registration)
Registering → Active (via successful registration)
Registering → Error (via registration failure)
Active → Migrating (via migration start)
Active → Offline (via connectivity loss)
Migrating → Active (via migration success)
Migrating → Error (via migration failure)
Offline → Active (via connectivity restored)
Error → Active (via error resolution)
Any State → Retired (via explicit retirement)
var StateTransitionRules = map[DeviceState][]DeviceState{
DeviceStateUnregistered: {DeviceStateDiscovered},
DeviceStateDiscovered: {DeviceStateRegistering, DeviceStateOffline},
DeviceStateRegistering: {DeviceStateActive, DeviceStateError},
DeviceStateActive: {DeviceStateMigrating, DeviceStateOffline, DeviceStateRetired},
DeviceStateMigrating: {DeviceStateActive, DeviceStateError},
DeviceStateOffline: {DeviceStateActive, DeviceStateError, DeviceStateRetired},
DeviceStateError: {DeviceStateActive, DeviceStateOffline, DeviceStateRetired},
DeviceStateRetired: {}, // Terminal state
}
type TransitionTrigger struct {
Event DeviceEventType
Condition func(*DeviceLifecycle, *DeviceEvent) bool
Target DeviceState
Reason string
}
var TransitionTriggers = []TransitionTrigger{
{
Event: EventTypeDeviceOnline,
Condition: isOfflineDevice,
Target: DeviceStateActive,
Reason: "connectivity_restored",
},
{
Event: EventTypeDeviceOffline,
Condition: isActiveDevice,
Target: DeviceStateOffline,
Reason: "connectivity_lost",
},
{
Event: EventTypeMigrationStart,
Condition: isActiveDevice,
Target: DeviceStateMigrating,
Reason: "migration_initiated",
},
// ... additional triggers
}
type EventQueue struct {
buffer chan DeviceEvent
processors []EventProcessor
storage EventStorage
config EventQueueConfig
}
type EventQueueConfig struct {
BufferSize int `json:"buffer_size"`
ProcessorCount int `json:"processor_count"`
FlushInterval time.Duration `json:"flush_interval"`
RetryAttempts int `json:"retry_attempts"`
DeadLetterQueue bool `json:"dead_letter_queue"`
}
type EventProcessor interface {
ProcessEvent(event DeviceEvent) error
CanHandle(eventType DeviceEventType) bool
}
Event Input → Validation → Queue → Processing → Storage → Notification
↓ ↓ ↓ ↓ ↓ ↓
Websocket Schema Buffer Parallel Files Webhooks
Discovery Check Memory Workers Logs SSE
API Call Format Retry DB Metrics
System Enrich DLQ
type RetentionPolicy struct {
EventType DeviceEventType `json:"event_type"`
RetentionDays int `json:"retention_days"`
MaxCount int `json:"max_count"`
Compression bool `json:"compression"`
}
var DefaultRetentionPolicies = []RetentionPolicy{
{EventTypeNowPlaying, 7, 1000, true},
{EventTypeVolumeChanged, 1, 100, false},
{EventTypeDisparityFound, 30, 10000, true},
{EventTypeMigrationStart, 365, -1, false}, // Keep forever
{EventTypeHealthCheck, 7, 1000, true},
}
Every change must pass these checks:
golangci-lint run --fix - no linting issuesgo test ./... - all tests pass with no failures| Aspect | Simple Approach | Optimization Only When Needed |
|---|---|---|
| Memory Usage | Direct file operations, minimal caching | Add caching if performance issues arise |
| CPU Usage | Synchronous processing initially | Add async processing if bottlenecks occur |
| Storage | Simple append operations | Add rotation/compression when files grow large |
| Networking | Reuse existing patterns | Optimize only if latency becomes problematic |
// Example test structure
func TestAccountManager_CreateAccount(t *testing.T) {
tests := []struct {
name string
input CreateAccountRequest
want *Account
wantErr bool
}{
{
name: "valid account creation",
input: CreateAccountRequest{Name: "Test Account"},
want: &Account{Name: "Test Account", Status: "active"},
wantErr: false,
},
{
name: "empty name should fail",
input: CreateAccountRequest{Name: ""},
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := manager.CreateAccount(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("CreateAccount() error = %v, wantErr %v", err, tt.wantErr)
return
}
// Additional assertions...
})
}
}
# Required before each commit
golangci-lint run --fix
go test ./...
go test -race ./...
# Required before milestone completion
go test ./... -v -cover
go test -bench=. ./...
// Simple validation approach
func ValidateAccount(account *Account) error {
if account.Name == "" {
return errors.New("account name cannot be empty")
}
if len(account.Name) > 100 {
return errors.New("account name too long")
}
if account.Email != "" && !isValidEmail(account.Email) {
return errors.New("invalid email format")
}
return nil
}
type ErrorCategory string
const (
ErrorCategoryValidation ErrorCategory = "validation"
ErrorCategorySystem ErrorCategory = "system"
ErrorCategoryNetwork ErrorCategory = "network"
ErrorCategoryStorage ErrorCategory = "storage"
ErrorCategoryTimeout ErrorCategory = "timeout"
ErrorCategoryAuth ErrorCategory = "authentication"
)
type ServiceError struct {
Code string `json:"code"`
Message string `json:"message"`
Category ErrorCategory `json:"category"`
Timestamp time.Time `json:"timestamp"`
Context map[string]interface{} `json:"context"`
Retryable bool `json:"retryable"`
Severity string `json:"severity"`
}
{
"error": {
"code": "DEVICE_NOT_FOUND",
"message": "Device with ID 'A81B6A536A98' not found in account 'acc_12345'",
"category": "validation",
"timestamp": "2024-01-20T15:30:00Z",
"context": {
"account_id": "acc_12345",
"device_id": "A81B6A536A98",
"request_id": "req_67890"
},
"retryable": false,
"severity": "error"
},
"request_id": "req_67890"
}
// Simple health check implementation
type HealthStatus struct {
Status string `json:"status"` // "healthy", "warning", "error"
Timestamp time.Time `json:"timestamp"`
Version string `json:"version"`
Uptime string `json:"uptime"`
}
func (s *Server) HandleHealthCheck(w http.ResponseWriter, r *http.Request) {
health := HealthStatus{
Status: "healthy",
Timestamp: time.Now(),
Version: version,
Uptime: time.Since(startTime).String(),
}
// Simple checks
if !s.canWriteToDataDir() {
health.Status = "error"
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(health)
}
// Basic counters - no complex metrics initially
type SimpleMetrics struct {
AccountsCreated int `json:"accounts_created"`
DevicesActive int `json:"devices_active"`
EventsProcessed int `json:"events_processed_today"`
LastUpdate time.Time `json:"last_update"`
}
// Update metrics in simple text file
func (m *SimpleMetrics) Save(dataDir string) error {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dataDir, "metrics.json"), data, 0644)
}
This technical specification provides comprehensive details for implementing the enhanced state management system while maintaining compatibility with existing SoundTouch service functionality and meeting the performance requirements for small hardware deployments.