mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 17:16:16 +00:00
feat: Add comprehensive zone management for multiroom control
- Implement GET /getZone and POST /setZone endpoints
- Add complete zone models with validation and error handling
- Create zone builder pattern for fluent API construction
- Add all zone operations: create, add, remove, dissolve
- Implement zone status queries and member management
- Add comprehensive CLI support for all zone commands
- Create 100+ tests covering all zone functionality
- Add WebSocket zone event handling and monitoring
- Include extensive documentation and examples
- Update project completion to 90% (18/20 endpoints)
BREAKING: None - all additions are backward compatible
NEW ENDPOINTS: /getZone (GET), /setZone (POST)
NEW CLI COMMANDS: -zone, -zone-status, -zone-members, -create-zone,
-add-to-zone, -remove-from-zone, -dissolve-zone
This commit is contained in:
@@ -4,7 +4,7 @@ A modern Go library and CLI tool for interacting with Bose SoundTouch devices vi
|
||||
|
||||
## Features
|
||||
|
||||
### ✅ Implemented (85% Complete - 16/19 endpoints)
|
||||
### ✅ Implemented (90% Complete - 18/20 endpoints)
|
||||
- **HTTP Client with XML Support**: Complete client for SoundTouch Web API
|
||||
- **Device Information**: Get detailed device info via `/info` endpoint
|
||||
- **Device Name**: Get device name via `/name` endpoint
|
||||
@@ -27,9 +27,14 @@ A modern Go library and CLI tool for interacting with Bose SoundTouch devices vi
|
||||
- **Unified Discovery**: Combines UPnP, mDNS, and configured device lists
|
||||
- **Safety Features**: Volume warnings, increment limits, error validation
|
||||
|
||||
### 🔄 Remaining High Priority (15% - 3/19 endpoints)
|
||||
- **Device System**: POST /reboot for device restart
|
||||
- **Multiroom Support**: GET/POST /getZone, /setZone (if supported by device)
|
||||
### ✅ Recently Completed - Zone Management
|
||||
- **Multiroom Support**: GET/POST /getZone, /setZone with comprehensive zone operations
|
||||
- **Zone Operations**: Create, modify, add members, remove members, dissolve zones
|
||||
- **Zone Status**: Query zone membership, master/slave status, device counting
|
||||
|
||||
### 🔄 Remaining High Priority (10% - 2/20 endpoints)
|
||||
- **Device System**: POST /reboot for device restart
|
||||
- **Advanced Zones**: Additional multiroom features (if supported by specific devices)
|
||||
|
||||
### ❌ Not Supported by API
|
||||
- **Preset Creation**: POST /presets (officially not supported by SoundTouch API)
|
||||
@@ -644,30 +649,48 @@ Bose-SoundTouch/
|
||||
| `/clockTime` | GET/POST | ✅ Complete | Device time management |
|
||||
| `/clockDisplay` | GET/POST | ✅ Complete | Clock display settings |
|
||||
| `/networkInfo` | GET | ✅ Complete | Network connectivity information |
|
||||
| **WebSocket** | `/` | ✅ **NEW** | **Real-time event monitoring** |
|
||||
| **WebSocket** | `/` | ✅ Complete | **Real-time event monitoring** |
|
||||
| **Discovery** | UPnP/mDNS | ✅ Complete | Device discovery services |
|
||||
| `/getZone` | GET | ✅ **NEW** | **Multiroom zone information** |
|
||||
| `/setZone` | POST | ✅ **NEW** | **Zone creation and management** |
|
||||
| `/reboot` | POST | 🔄 Planned | Device restart |
|
||||
| `/getZone` | GET | 🔄 Planned | Multiroom zone info |
|
||||
| `/setZone` | POST | 🔄 Planned | Multiroom zone configuration |
|
||||
|
||||
### Zone Management Features ✅ **NEW**
|
||||
|
||||
| Feature | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| Zone Information | ✅ Complete | Get current zone configuration |
|
||||
| Zone Status | ✅ Complete | Check master/slave status for device |
|
||||
| Zone Members | ✅ Complete | List all devices in zone |
|
||||
| Create Zone | ✅ Complete | Create multiroom zone with multiple devices |
|
||||
| Add to Zone | ✅ Complete | Add device to existing zone |
|
||||
| Remove from Zone | ✅ Complete | Remove device from zone |
|
||||
| Dissolve Zone | ✅ Complete | Break up zone, make devices standalone |
|
||||
| Zone Builder | ✅ Complete | Fluent API for zone construction |
|
||||
| Validation | ✅ Complete | IP validation, duplicate detection |
|
||||
| Error Handling | ✅ Complete | Comprehensive zone error types |
|
||||
|
||||
## Testing Coverage
|
||||
|
||||
- **Unit Tests**: 150+ test cases covering all functionality
|
||||
- **Unit Tests**: 200+ test cases covering all functionality
|
||||
- **Integration Tests**: Real device testing scenarios
|
||||
- **Benchmark Tests**: Performance validation
|
||||
- **WebSocket Tests**: Comprehensive event handling tests
|
||||
- **Zone Management Tests**: Complete multiroom functionality tests
|
||||
- **Discovery Tests**: Multi-protocol device discovery tests
|
||||
|
||||
```go
|
||||
// Run all tests
|
||||
go test ./... -v
|
||||
|
||||
// Run specific test suites
|
||||
# Run specific test suites
|
||||
go test ./pkg/client -v -run TestWebSocket
|
||||
go test ./pkg/models -v -run TestWebSocket
|
||||
go test ./pkg/client -v -run TestZone
|
||||
go test ./pkg/models -v -run TestZone
|
||||
go test ./pkg/discovery -v
|
||||
|
||||
// Run benchmarks
|
||||
# Run benchmarks
|
||||
go test ./pkg/client -bench=.
|
||||
go test ./pkg/models -bench=.
|
||||
```
|
||||
|
||||
+345
-51
@@ -42,56 +42,63 @@ func parseHostPort(hostPort string, defaultPort int) (string, int) {
|
||||
|
||||
func main() {
|
||||
var (
|
||||
host = flag.String("host", "", "SoundTouch device host/IP address (can include port like host:8090)")
|
||||
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")
|
||||
nowPlaying = flag.Bool("nowplaying", false, "Get current playback status")
|
||||
sources = flag.Bool("sources", false, "Get available audio sources")
|
||||
name = flag.Bool("name", false, "Get device name")
|
||||
capabilities = flag.Bool("capabilities", false, "Get device capabilities")
|
||||
presets = flag.Bool("presets", false, "Get configured presets (requires -host)")
|
||||
key = flag.String("key", "", "Send key command (PLAY, PAUSE, STOP, PREV_TRACK, NEXT_TRACK, THUMBS_UP, THUMBS_DOWN, BOOKMARK, POWER, MUTE, VOLUME_UP, VOLUME_DOWN, PRESET_1-6, AUX_INPUT, SHUFFLE_OFF, SHUFFLE_ON, REPEAT_OFF, REPEAT_ONE, REPEAT_ALL)")
|
||||
play = flag.Bool("play", false, "Send PLAY key command")
|
||||
pause = flag.Bool("pause", false, "Send PAUSE key command")
|
||||
stop = flag.Bool("stop", false, "Send STOP key command")
|
||||
next = flag.Bool("next", false, "Send NEXT_TRACK key command")
|
||||
prev = flag.Bool("prev", false, "Send PREV_TRACK key command")
|
||||
volumeUp = flag.Bool("volume-up", false, "Send VOLUME_UP key command")
|
||||
volumeDown = flag.Bool("volume-down", false, "Send VOLUME_DOWN key command")
|
||||
power = flag.Bool("power", false, "Send POWER key command")
|
||||
mute = flag.Bool("mute", false, "Send MUTE key command")
|
||||
thumbsUp = flag.Bool("thumbs-up", false, "Send THUMBS_UP key command")
|
||||
thumbsDown = flag.Bool("thumbs-down", false, "Send THUMBS_DOWN key command")
|
||||
preset = flag.Int("preset", 0, "Select preset (1-6)")
|
||||
volume = flag.Bool("volume", false, "Get current volume level")
|
||||
setVolume = flag.Int("set-volume", -1, "Set volume level (0-100)")
|
||||
incVolume = flag.Int("inc-volume", 0, "Increase volume by amount (1-10, default: 2)")
|
||||
decVolume = flag.Int("dec-volume", 0, "Decrease volume by amount (1-10, default: 2)")
|
||||
bass = flag.Bool("bass", false, "Get current bass level")
|
||||
setBass = flag.Int("set-bass", -99, "Set bass level (-9 to +9)")
|
||||
incBass = flag.Int("inc-bass", 0, "Increase bass by amount (1-3, default: 1)")
|
||||
decBass = flag.Int("dec-bass", 0, "Decrease bass by amount (1-3, default: 1)")
|
||||
balance = flag.Bool("balance", false, "Get current balance level")
|
||||
setBalance = flag.Int("set-balance", -99, "Set balance level (-50 to +50)")
|
||||
incBalance = flag.Int("inc-balance", 0, "Increase balance by amount (1-10, default: 5)")
|
||||
decBalance = flag.Int("dec-balance", 0, "Decrease balance by amount (1-10, default: 5)")
|
||||
selectSource = flag.String("select-source", "", "Select audio source (SPOTIFY, BLUETOOTH, AUX, TUNEIN, PANDORA, AMAZON, IHEARTRADIO, STORED_MUSIC)")
|
||||
sourceAccount = flag.String("source-account", "", "Source account for streaming services (optional)")
|
||||
spotify = flag.Bool("spotify", false, "Select Spotify source")
|
||||
bluetooth = flag.Bool("bluetooth", false, "Select Bluetooth source")
|
||||
aux = flag.Bool("aux", false, "Select AUX input source")
|
||||
clockTime = flag.Bool("clock-time", false, "Get device clock time")
|
||||
setClockTime = flag.String("set-clock-time", "", "Set device clock time (format: 'now' or Unix timestamp)")
|
||||
clockDisplay = flag.Bool("clock-display", false, "Get clock display settings")
|
||||
enableClock = flag.Bool("enable-clock", false, "Enable clock display")
|
||||
disableClock = flag.Bool("disable-clock", false, "Disable clock display")
|
||||
clockFormat = flag.String("clock-format", "", "Set clock display format (12, 24, auto)")
|
||||
clockBright = flag.Int("clock-brightness", -1, "Set clock display brightness (0-100)")
|
||||
networkInfo = flag.Bool("network-info", false, "Get network information")
|
||||
help = flag.Bool("help", false, "Show help")
|
||||
host = flag.String("host", "", "SoundTouch device host/IP address (can include port like host:8090)")
|
||||
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")
|
||||
nowPlaying = flag.Bool("nowplaying", false, "Get current playback status")
|
||||
sources = flag.Bool("sources", false, "Get available audio sources")
|
||||
name = flag.Bool("name", false, "Get device name")
|
||||
capabilities = flag.Bool("capabilities", false, "Get device capabilities")
|
||||
presets = flag.Bool("presets", false, "Get configured presets (requires -host)")
|
||||
key = flag.String("key", "", "Send key command (PLAY, PAUSE, STOP, PREV_TRACK, NEXT_TRACK, THUMBS_UP, THUMBS_DOWN, BOOKMARK, POWER, MUTE, VOLUME_UP, VOLUME_DOWN, PRESET_1-6, AUX_INPUT, SHUFFLE_OFF, SHUFFLE_ON, REPEAT_OFF, REPEAT_ONE, REPEAT_ALL)")
|
||||
play = flag.Bool("play", false, "Send PLAY key command")
|
||||
pause = flag.Bool("pause", false, "Send PAUSE key command")
|
||||
stop = flag.Bool("stop", false, "Send STOP key command")
|
||||
next = flag.Bool("next", false, "Send NEXT_TRACK key command")
|
||||
prev = flag.Bool("prev", false, "Send PREV_TRACK key command")
|
||||
volumeUp = flag.Bool("volume-up", false, "Send VOLUME_UP key command")
|
||||
volumeDown = flag.Bool("volume-down", false, "Send VOLUME_DOWN key command")
|
||||
power = flag.Bool("power", false, "Send POWER key command")
|
||||
mute = flag.Bool("mute", false, "Send MUTE key command")
|
||||
thumbsUp = flag.Bool("thumbs-up", false, "Send THUMBS_UP key command")
|
||||
thumbsDown = flag.Bool("thumbs-down", false, "Send THUMBS_DOWN key command")
|
||||
preset = flag.Int("preset", 0, "Select preset (1-6)")
|
||||
volume = flag.Bool("volume", false, "Get current volume level")
|
||||
setVolume = flag.Int("set-volume", -1, "Set volume level (0-100)")
|
||||
incVolume = flag.Int("inc-volume", 0, "Increase volume by amount (1-10, default: 2)")
|
||||
decVolume = flag.Int("dec-volume", 0, "Decrease volume by amount (1-10, default: 2)")
|
||||
bass = flag.Bool("bass", false, "Get current bass level")
|
||||
setBass = flag.Int("set-bass", -99, "Set bass level (-9 to +9)")
|
||||
incBass = flag.Int("inc-bass", 0, "Increase bass by amount (1-3, default: 1)")
|
||||
decBass = flag.Int("dec-bass", 0, "Decrease bass by amount (1-3, default: 1)")
|
||||
balance = flag.Bool("balance", false, "Get current balance level")
|
||||
setBalance = flag.Int("set-balance", -99, "Set balance level (-50 to +50)")
|
||||
incBalance = flag.Int("inc-balance", 0, "Increase balance by amount (1-10, default: 5)")
|
||||
decBalance = flag.Int("dec-balance", 0, "Decrease balance by amount (1-10, default: 5)")
|
||||
selectSource = flag.String("select-source", "", "Select audio source (SPOTIFY, BLUETOOTH, AUX, TUNEIN, PANDORA, AMAZON, IHEARTRADIO, STORED_MUSIC)")
|
||||
sourceAccount = flag.String("source-account", "", "Source account for streaming services (optional)")
|
||||
spotify = flag.Bool("spotify", false, "Select Spotify source")
|
||||
bluetooth = flag.Bool("bluetooth", false, "Select Bluetooth source")
|
||||
aux = flag.Bool("aux", false, "Select AUX input source")
|
||||
clockTime = flag.Bool("clock-time", false, "Get device clock time")
|
||||
setClockTime = flag.String("set-clock-time", "", "Set device clock time (format: 'now' or Unix timestamp)")
|
||||
clockDisplay = flag.Bool("clock-display", false, "Get clock display settings")
|
||||
enableClock = flag.Bool("enable-clock", false, "Enable clock display")
|
||||
disableClock = flag.Bool("disable-clock", false, "Disable clock display")
|
||||
clockFormat = flag.String("clock-format", "", "Set clock display format (12, 24, auto)")
|
||||
clockBright = flag.Int("clock-brightness", -1, "Set clock display brightness (0-100)")
|
||||
networkInfo = flag.Bool("network-info", false, "Get network information")
|
||||
zone = flag.Bool("zone", false, "Get current zone configuration")
|
||||
zoneStatus = flag.Bool("zone-status", false, "Get zone status for this device")
|
||||
zoneMembers = flag.Bool("zone-members", false, "List all devices in current zone")
|
||||
createZone = flag.String("create-zone", "", "Create zone with device IDs (comma-separated)")
|
||||
addToZone = flag.String("add-to-zone", "", "Add device to zone (format: deviceID@ip or deviceID)")
|
||||
removeFromZone = flag.String("remove-from-zone", "", "Remove device from zone (device ID)")
|
||||
dissolveZone = flag.Bool("dissolve-zone", false, "Dissolve current zone (make standalone)")
|
||||
help = flag.Bool("help", false, "Show help")
|
||||
)
|
||||
|
||||
flag.Parse()
|
||||
@@ -102,7 +109,7 @@ func main() {
|
||||
}
|
||||
|
||||
// If no specific action is requested, show help
|
||||
if !*discover && !*discoverAll && !*info && !*nowPlaying && !*sources && !*name && !*capabilities && !*presets && *key == "" && !*play && !*pause && !*stop && !*next && !*prev && !*volumeUp && !*volumeDown && !*power && !*mute && !*thumbsUp && !*thumbsDown && *preset == 0 && !*volume && *setVolume == -1 && *incVolume == 0 && *decVolume == 0 && !*bass && *setBass == -99 && *incBass == 0 && *decBass == 0 && !*balance && *setBalance == -99 && *incBalance == 0 && *decBalance == 0 && *selectSource == "" && !*spotify && !*bluetooth && !*aux && !*clockTime && *setClockTime == "" && !*clockDisplay && !*enableClock && !*disableClock && *clockFormat == "" && *clockBright == -1 && !*networkInfo && *host == "" {
|
||||
if !*discover && !*discoverAll && !*info && !*nowPlaying && !*sources && !*name && !*capabilities && !*presets && *key == "" && !*play && !*pause && !*stop && !*next && !*prev && !*volumeUp && !*volumeDown && !*power && !*mute && !*thumbsUp && !*thumbsDown && *preset == 0 && !*volume && *setVolume == -1 && *incVolume == 0 && *decVolume == 0 && !*bass && *setBass == -99 && *incBass == 0 && *decBass == 0 && !*balance && *setBalance == -99 && *incBalance == 0 && *decBalance == 0 && *selectSource == "" && !*spotify && !*bluetooth && !*aux && !*clockTime && *setClockTime == "" && !*clockDisplay && !*enableClock && !*disableClock && *clockFormat == "" && *clockBright == -1 && !*networkInfo && !*zone && !*zoneStatus && !*zoneMembers && *createZone == "" && *addToZone == "" && *removeFromZone == "" && !*dissolveZone && *host == "" {
|
||||
printHelp()
|
||||
return
|
||||
}
|
||||
@@ -264,6 +271,17 @@ func main() {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle zone commands
|
||||
if *zone || *zoneStatus || *zoneMembers || *createZone != "" || *addToZone != "" || *removeFromZone != "" || *dissolveZone {
|
||||
if *host == "" {
|
||||
log.Fatal("Host is required for zone commands. Use -host flag or -discover to find devices.")
|
||||
}
|
||||
if err := handleZoneCommands(finalHost, finalPort, *timeout, *zone, *zoneStatus, *zoneMembers, *createZone, *addToZone, *removeFromZone, *dissolveZone); err != nil {
|
||||
log.Fatalf("Failed to execute zone command: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func printHelp() {
|
||||
@@ -337,6 +355,17 @@ func printHelp() {
|
||||
fmt.Println(" -clock-brightness <0-100> Set clock brightness (requires -host)")
|
||||
fmt.Println(" -network-info Get network information (requires -host)")
|
||||
fmt.Println()
|
||||
fmt.Println("Zone Management:")
|
||||
fmt.Println(" -zone Get current zone configuration (requires -host)")
|
||||
fmt.Println(" -zone-status Get zone status for this device (requires -host)")
|
||||
fmt.Println(" -zone-members List all devices in current zone (requires -host)")
|
||||
fmt.Println(" -create-zone <devices> Create zone with device IDs (comma-separated)")
|
||||
fmt.Println(" Format: masterID,memberID1,memberID2,...")
|
||||
fmt.Println(" -add-to-zone <device> Add device to zone (requires -host)")
|
||||
fmt.Println(" Format: deviceID@ip or deviceID")
|
||||
fmt.Println(" -remove-from-zone <deviceID> Remove device from zone (requires -host)")
|
||||
fmt.Println(" -dissolve-zone Dissolve current zone, make device standalone (requires -host)")
|
||||
fmt.Println()
|
||||
fmt.Println("Examples:")
|
||||
fmt.Println(" soundtouch-cli -discover")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.10 -info")
|
||||
@@ -361,6 +390,13 @@ func printHelp() {
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.10 -clock-format 24")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.10 -clock-brightness 75")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.10 -network-info")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.10 -zone")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.10 -zone-status")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.10 -zone-members")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.10 -create-zone MASTER123,DEVICE456,DEVICE789")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.10 -add-to-zone DEVICE456@192.168.1.11")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.10 -remove-from-zone DEVICE456")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.10 -dissolve-zone")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.10 -play")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.10:8090 -pause")
|
||||
fmt.Println(" soundtouch-cli -host 192.168.1.10 -volume-up")
|
||||
@@ -1753,3 +1789,261 @@ func handleNetworkInfo(host string, port int, timeout time.Duration) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleZoneCommands handles all zone management commands
|
||||
func handleZoneCommands(host string, port int, timeout time.Duration, getZone, getZoneStatus, getZoneMembers bool, createZone, addToZone, removeFromZone string, dissolveZone bool) error {
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
// Override config with command line arguments if provided
|
||||
if timeout > 0 {
|
||||
cfg.HTTPTimeout = timeout
|
||||
}
|
||||
|
||||
clientConfig := client.ClientConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: cfg.HTTPTimeout,
|
||||
UserAgent: cfg.UserAgent,
|
||||
}
|
||||
|
||||
soundtouchClient := client.NewClient(clientConfig)
|
||||
|
||||
// Handle get zone info
|
||||
if getZone {
|
||||
return handleGetZone(soundtouchClient)
|
||||
}
|
||||
|
||||
// Handle get zone status
|
||||
if getZoneStatus {
|
||||
return handleGetZoneStatus(soundtouchClient)
|
||||
}
|
||||
|
||||
// Handle get zone members
|
||||
if getZoneMembers {
|
||||
return handleGetZoneMembers(soundtouchClient)
|
||||
}
|
||||
|
||||
// Handle create zone
|
||||
if createZone != "" {
|
||||
return handleCreateZone(soundtouchClient, createZone)
|
||||
}
|
||||
|
||||
// Handle add to zone
|
||||
if addToZone != "" {
|
||||
return handleAddToZone(soundtouchClient, addToZone)
|
||||
}
|
||||
|
||||
// Handle remove from zone
|
||||
if removeFromZone != "" {
|
||||
return handleRemoveFromZone(soundtouchClient, removeFromZone)
|
||||
}
|
||||
|
||||
// Handle dissolve zone
|
||||
if dissolveZone {
|
||||
return handleDissolveZone(soundtouchClient)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleGetZone displays current zone configuration
|
||||
func handleGetZone(client *client.Client) error {
|
||||
zone, err := client.GetZone()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Zone Configuration:\n")
|
||||
fmt.Printf(" Master Device: %s\n", zone.Master)
|
||||
|
||||
if zone.IsStandalone() {
|
||||
fmt.Printf(" Status: Standalone (no multiroom zone)\n")
|
||||
fmt.Printf(" Total Devices: 1\n")
|
||||
} else {
|
||||
fmt.Printf(" Status: Active multiroom zone\n")
|
||||
fmt.Printf(" Total Devices: %d\n", zone.GetTotalDeviceCount())
|
||||
fmt.Printf(" Zone Members:\n")
|
||||
for i, member := range zone.Members {
|
||||
fmt.Printf(" %d. %s", i+1, member.DeviceID)
|
||||
if member.IP != "" {
|
||||
fmt.Printf(" (%s)", member.IP)
|
||||
}
|
||||
fmt.Printf("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleGetZoneStatus displays zone status for this device
|
||||
func handleGetZoneStatus(client *client.Client) error {
|
||||
status, err := client.GetZoneStatus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get device info for context
|
||||
deviceInfo, err := client.GetDeviceInfo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Zone Status for %s (%s):\n", deviceInfo.Name, deviceInfo.DeviceID)
|
||||
fmt.Printf(" Status: %s\n", status.String())
|
||||
|
||||
// Show additional zone info if in a zone
|
||||
if status != models.ZoneStatusStandalone {
|
||||
zone, err := client.GetZone()
|
||||
if err == nil {
|
||||
if status == models.ZoneStatusMaster {
|
||||
fmt.Printf(" Zone Members: %d\n", len(zone.Members))
|
||||
} else {
|
||||
fmt.Printf(" Zone Master: %s\n", zone.Master)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleGetZoneMembers lists all devices in the current zone
|
||||
func handleGetZoneMembers(client *client.Client) error {
|
||||
members, err := client.GetZoneMembers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
zone, err := client.GetZone()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Zone Members:\n")
|
||||
if len(members) == 1 {
|
||||
fmt.Printf(" Device is standalone (not in a zone)\n")
|
||||
fmt.Printf(" Device: %s\n", members[0])
|
||||
} else {
|
||||
fmt.Printf(" Total Devices: %d\n", len(members))
|
||||
fmt.Printf(" Master: %s\n", zone.Master)
|
||||
fmt.Printf(" Members:\n")
|
||||
for i, memberID := range members {
|
||||
if memberID == zone.Master {
|
||||
fmt.Printf(" %d. %s (Master)\n", i+1, memberID)
|
||||
} else {
|
||||
// Find IP address if available
|
||||
var ip string
|
||||
if member, found := zone.GetMemberByDeviceID(memberID); found {
|
||||
ip = member.IP
|
||||
}
|
||||
fmt.Printf(" %d. %s", i+1, memberID)
|
||||
if ip != "" {
|
||||
fmt.Printf(" (%s)", ip)
|
||||
}
|
||||
fmt.Printf("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCreateZone creates a new multiroom zone
|
||||
func handleCreateZone(client *client.Client, deviceList string) error {
|
||||
deviceIDs := strings.Split(deviceList, ",")
|
||||
if len(deviceIDs) < 2 {
|
||||
return fmt.Errorf("at least 2 devices required for zone creation (master + 1 member)")
|
||||
}
|
||||
|
||||
// Clean up device IDs
|
||||
for i := range deviceIDs {
|
||||
deviceIDs[i] = strings.TrimSpace(deviceIDs[i])
|
||||
}
|
||||
|
||||
masterID := deviceIDs[0]
|
||||
memberIDs := deviceIDs[1:]
|
||||
|
||||
fmt.Printf("Creating zone with master %s and %d member(s)...\n", masterID, len(memberIDs))
|
||||
|
||||
err := client.CreateZone(masterID, memberIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create zone: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Zone created successfully\n")
|
||||
fmt.Printf(" Master: %s\n", masterID)
|
||||
fmt.Printf(" Members: %s\n", strings.Join(memberIDs, ", "))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAddToZone adds a device to the current zone
|
||||
func handleAddToZone(client *client.Client, deviceSpec string) error {
|
||||
var deviceID, ipAddress string
|
||||
|
||||
// Parse device specification (deviceID@ip or just deviceID)
|
||||
if strings.Contains(deviceSpec, "@") {
|
||||
parts := strings.Split(deviceSpec, "@")
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("invalid device specification. Use format: deviceID@ip or deviceID")
|
||||
}
|
||||
deviceID = strings.TrimSpace(parts[0])
|
||||
ipAddress = strings.TrimSpace(parts[1])
|
||||
} else {
|
||||
deviceID = strings.TrimSpace(deviceSpec)
|
||||
}
|
||||
|
||||
if deviceID == "" {
|
||||
return fmt.Errorf("device ID cannot be empty")
|
||||
}
|
||||
|
||||
fmt.Printf("Adding device %s to zone", deviceID)
|
||||
if ipAddress != "" {
|
||||
fmt.Printf(" (IP: %s)", ipAddress)
|
||||
}
|
||||
fmt.Printf("...\n")
|
||||
|
||||
err := client.AddToZone(deviceID, ipAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add device to zone: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Device %s added to zone successfully\n", deviceID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleRemoveFromZone removes a device from the current zone
|
||||
func handleRemoveFromZone(client *client.Client, deviceID string) error {
|
||||
deviceID = strings.TrimSpace(deviceID)
|
||||
if deviceID == "" {
|
||||
return fmt.Errorf("device ID cannot be empty")
|
||||
}
|
||||
|
||||
fmt.Printf("Removing device %s from zone...\n", deviceID)
|
||||
|
||||
err := client.RemoveFromZone(deviceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove device from zone: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Device %s removed from zone successfully\n", deviceID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDissolveZone dissolves the current zone
|
||||
func handleDissolveZone(client *client.Client) error {
|
||||
fmt.Printf("Dissolving current zone (making device standalone)...\n")
|
||||
|
||||
err := client.DissolveZone()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to dissolve zone: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Zone dissolved successfully - device is now standalone\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+13
-2
@@ -65,13 +65,13 @@ This project implements a comprehensive Go client library and CLI tool for Bose
|
||||
|
||||
### **Remaining Endpoints - LOW PRIORITY**
|
||||
- `POST /reboot` - Device restart
|
||||
- `GET /getZone`, `POST /setZone` - Multiroom zones (if supported by device)
|
||||
|
||||
### **✅ Recently Completed**
|
||||
- `GET /clockTime`, `POST /clockTime` - Device time ✅ Complete
|
||||
- `GET /clockDisplay`, `POST /clockDisplay` - Clock display ✅ Complete
|
||||
- `GET /networkInfo` - Network information ✅ Complete
|
||||
- `WebSocket /` - Real-time event streaming ✅ Complete
|
||||
- `GET /getZone`, `POST /setZone` - Multiroom zone management ✅ Complete
|
||||
|
||||
### **❌ Not Supported by API**
|
||||
- `POST /presets` - Preset creation (officially marked as "N/A" by Bose)
|
||||
@@ -85,8 +85,9 @@ This project implements a comprehensive Go client library and CLI tool for Bose
|
||||
| **System Endpoints** | 5/5 | 5 | 100% |
|
||||
| **Real-time Features** | 1/1 | 1 | 100% |
|
||||
| **Preset Management** | 1/1 | 1 | 100% |
|
||||
| **Zone Management** | 2/2 | 2 | 100% |
|
||||
| **~~Preset Creation~~** | ~~0/1~~ | ~~1~~ | **N/A - Not Supported by API** |
|
||||
| **Overall Progress** | 16/19 | 19 | **85%** |
|
||||
| **Overall Progress** | 18/20 | 20 | **90%** |
|
||||
|
||||
## 🏆 Major Accomplishments
|
||||
|
||||
@@ -117,6 +118,14 @@ This project implements a comprehensive Go client library and CLI tool for Bose
|
||||
- ✅ mDNS discovery support alongside UPnP
|
||||
- ✅ Unified discovery service combining multiple protocols
|
||||
|
||||
### Phase 4: Multiroom & Zone Management (COMPLETE)
|
||||
- ✅ Zone information retrieval (GET /getZone)
|
||||
- ✅ Zone configuration management (POST /setZone)
|
||||
- ✅ Complete zone operations (create, modify, add, remove, dissolve)
|
||||
- ✅ Zone status and membership queries
|
||||
- ✅ Comprehensive validation and error handling
|
||||
- ✅ CLI integration for all zone operations
|
||||
|
||||
### Key Technical Achievements
|
||||
- **Complete Key Controls**: All 24 documented key commands implemented
|
||||
- **Source Selection**: Full source switching with convenience methods (-spotify, -bluetooth, -aux)
|
||||
@@ -124,6 +133,8 @@ This project implements a comprehensive Go client library and CLI tool for Bose
|
||||
- **Balance Control**: Stereo balance adjustment with left/right channel control
|
||||
- **Preset Management**: Complete preset analysis with helper methods (read-only by API design)
|
||||
- **Real-time Events**: WebSocket client with 12 event types and automatic reconnection
|
||||
- **Zone Management**: Complete multiroom zone operations with validation
|
||||
- **Zone Status**: Query zone membership, master/slave status, device counting
|
||||
- **System Management**: Clock time, display settings, and network information
|
||||
- **API Compliance**: Proper press+release key pattern implementation
|
||||
- **Safety First**: Volume warnings and limits for user protection
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
# Zone Management - Multiroom SoundTouch Control
|
||||
|
||||
This document describes the comprehensive zone management functionality for controlling multiroom setups with Bose SoundTouch devices.
|
||||
|
||||
## Overview
|
||||
|
||||
Zone management allows you to:
|
||||
|
||||
- **Create multiroom zones** with multiple SoundTouch devices
|
||||
- **Query zone status** and membership information
|
||||
- **Add and remove devices** from existing zones
|
||||
- **Dissolve zones** to make devices standalone
|
||||
- **Monitor zone changes** via WebSocket events
|
||||
|
||||
All SoundTouch devices that support multiroom functionality can participate in zones, with one device acting as the master and others as members.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Zone Operations
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/user_account/bose-soundtouch/pkg/client"
|
||||
"github.com/user_account/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Connect to SoundTouch device
|
||||
soundTouchClient := client.NewClientFromHost("192.168.1.10")
|
||||
|
||||
// Get current zone information
|
||||
zone, err := soundTouchClient.GetZone()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Zone Master: %s\n", zone.Master)
|
||||
fmt.Printf("Zone Members: %d\n", len(zone.Members))
|
||||
|
||||
if zone.IsStandalone() {
|
||||
fmt.Println("Device is standalone (not in a zone)")
|
||||
} else {
|
||||
fmt.Printf("Total devices in zone: %d\n", zone.GetTotalDeviceCount())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Zone Operations
|
||||
|
||||
```bash
|
||||
# Get zone information
|
||||
go run ./cmd/soundtouch-cli -host 192.168.1.10 -zone
|
||||
|
||||
# Check zone status for this device
|
||||
go run ./cmd/soundtouch-cli -host 192.168.1.10 -zone-status
|
||||
|
||||
# List all zone members
|
||||
go run ./cmd/soundtouch-cli -host 192.168.1.10 -zone-members
|
||||
|
||||
# Create a new zone
|
||||
go run ./cmd/soundtouch-cli -host 192.168.1.10 -create-zone MASTER123,MEMBER456,MEMBER789
|
||||
|
||||
# Add device to existing zone
|
||||
go run ./cmd/soundtouch-cli -host 192.168.1.10 -add-to-zone DEVICE456@192.168.1.11
|
||||
|
||||
# Remove device from zone
|
||||
go run ./cmd/soundtouch-cli -host 192.168.1.10 -remove-from-zone DEVICE456
|
||||
|
||||
# Dissolve current zone
|
||||
go run ./cmd/soundtouch-cli -host 192.168.1.10 -dissolve-zone
|
||||
```
|
||||
|
||||
## Zone Concepts
|
||||
|
||||
### Zone Hierarchy
|
||||
|
||||
- **Master Device**: Controls zone playback, receives commands
|
||||
- **Member Devices**: Follow master's playback, synchronized audio
|
||||
- **Standalone**: Single device not part of any zone
|
||||
|
||||
### Zone States
|
||||
|
||||
| State | Description |
|
||||
|-------|-------------|
|
||||
| `STANDALONE` | Device operates independently |
|
||||
| `MASTER` | Device controls a multiroom zone |
|
||||
| `SLAVE` | Device follows zone master |
|
||||
|
||||
## API Reference
|
||||
|
||||
### Zone Information
|
||||
|
||||
#### Get Zone Configuration
|
||||
|
||||
```go
|
||||
zone, err := client.GetZone()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Master: %s\n", zone.Master)
|
||||
for i, member := range zone.Members {
|
||||
fmt.Printf("Member %d: %s (%s)\n", i+1, member.DeviceID, member.IP)
|
||||
}
|
||||
```
|
||||
|
||||
**Response Structure:**
|
||||
```xml
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.168.1.11">EFGH5678IJKL</member>
|
||||
<member ipaddress="192.168.1.12">IJKL9012MNOP</member>
|
||||
</zone>
|
||||
```
|
||||
|
||||
#### Check Zone Status
|
||||
|
||||
```go
|
||||
status, err := client.GetZoneStatus()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("This device is: %s\n", status.String())
|
||||
```
|
||||
|
||||
#### Get Zone Members
|
||||
|
||||
```go
|
||||
members, err := client.GetZoneMembers()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Zone has %d devices:\n", len(members))
|
||||
for _, deviceID := range members {
|
||||
fmt.Printf(" - %s\n", deviceID)
|
||||
}
|
||||
```
|
||||
|
||||
### Zone Creation and Management
|
||||
|
||||
#### Create New Zone
|
||||
|
||||
```go
|
||||
// Simple zone creation
|
||||
masterID := "ABCD1234EFGH"
|
||||
memberIDs := []string{"EFGH5678IJKL", "IJKL9012MNOP"}
|
||||
|
||||
err := client.CreateZone(masterID, memberIDs)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
#### Create Zone with IP Addresses
|
||||
|
||||
```go
|
||||
masterID := "ABCD1234EFGH"
|
||||
members := map[string]string{
|
||||
"EFGH5678IJKL": "192.168.1.11",
|
||||
"IJKL9012MNOP": "192.168.1.12",
|
||||
}
|
||||
|
||||
err := client.CreateZoneWithIPs(masterID, members)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
#### Add Device to Zone
|
||||
|
||||
```go
|
||||
// Add device with IP address
|
||||
err := client.AddToZone("DEVICE789", "192.168.1.13")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
#### Remove Device from Zone
|
||||
|
||||
```go
|
||||
err := client.RemoveFromZone("DEVICE789")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
#### Dissolve Zone
|
||||
|
||||
```go
|
||||
// Make all devices standalone
|
||||
err := client.DissolveZone()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Zone Operations
|
||||
|
||||
#### Using Zone Builder
|
||||
|
||||
```go
|
||||
zoneRequest, err := models.NewZoneBuilder("MASTER123").
|
||||
WithMember("DEVICE456", "192.168.1.11").
|
||||
WithMemberByDeviceID("DEVICE789").
|
||||
Build()
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = client.SetZone(zoneRequest)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
#### Custom Zone Configuration
|
||||
|
||||
```go
|
||||
// Create zone request manually
|
||||
zoneRequest := models.NewZoneRequest("MASTER123")
|
||||
zoneRequest.AddMember("DEVICE456", "192.168.1.11")
|
||||
zoneRequest.AddMember("DEVICE789", "192.168.1.12")
|
||||
|
||||
// Validate before sending
|
||||
if err := zoneRequest.Validate(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err := client.SetZone(zoneRequest)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
### Zone Utility Methods
|
||||
|
||||
#### Zone Information Analysis
|
||||
|
||||
```go
|
||||
zone, _ := client.GetZone()
|
||||
|
||||
// Check zone status
|
||||
if zone.IsStandalone() {
|
||||
fmt.Println("No multiroom zone active")
|
||||
}
|
||||
|
||||
// Check device membership
|
||||
if zone.IsMaster("DEVICE123") {
|
||||
fmt.Println("DEVICE123 is the zone master")
|
||||
}
|
||||
|
||||
if zone.IsMember("DEVICE456") {
|
||||
fmt.Println("DEVICE456 is a zone member")
|
||||
}
|
||||
|
||||
// Find device by IP
|
||||
member, found := zone.GetMemberByIP("192.168.1.11")
|
||||
if found {
|
||||
fmt.Printf("Device at 192.168.1.11 is %s\n", member.DeviceID)
|
||||
}
|
||||
|
||||
// Get all device IDs
|
||||
allDevices := zone.GetAllDeviceIDs()
|
||||
fmt.Printf("Zone contains: %v\n", allDevices)
|
||||
```
|
||||
|
||||
## Real-time Zone Monitoring
|
||||
|
||||
### WebSocket Zone Events
|
||||
|
||||
```go
|
||||
wsClient := soundTouchClient.NewWebSocketClient(nil)
|
||||
|
||||
// Monitor zone changes
|
||||
wsClient.OnZoneUpdated(func(event *models.ZoneUpdatedEvent) {
|
||||
zone := &event.Zone
|
||||
|
||||
if zone.Master != "" {
|
||||
fmt.Printf("Zone updated - Master: %s\n", zone.Master)
|
||||
fmt.Printf("Members: %d\n", len(zone.Members))
|
||||
|
||||
for _, member := range zone.Members {
|
||||
fmt.Printf(" - %s (%s)\n", member.DeviceID, member.IP)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Zone dissolved - device is now standalone")
|
||||
}
|
||||
})
|
||||
|
||||
// Connect and start monitoring
|
||||
wsClient.Connect()
|
||||
defer wsClient.Disconnect()
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Zone-Specific Errors
|
||||
|
||||
```go
|
||||
err := client.AddToZone("INVALID_DEVICE", "192.168.1.99")
|
||||
if err != nil {
|
||||
// Handle specific zone errors
|
||||
if zoneErr, ok := err.(*models.ZoneError); ok {
|
||||
fmt.Printf("Zone operation %s failed: %s\n",
|
||||
zoneErr.Operation, zoneErr.Reason)
|
||||
|
||||
switch zoneErr.Reason {
|
||||
case models.ZoneErrorDeviceNotFound:
|
||||
fmt.Println("Device not found on network")
|
||||
case models.ZoneErrorDeviceOffline:
|
||||
fmt.Println("Device is offline")
|
||||
case models.ZoneErrorAlreadyInZone:
|
||||
fmt.Println("Device already in a zone")
|
||||
case models.ZoneErrorMaxMembersReached:
|
||||
fmt.Println("Maximum zone size reached")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Validation Errors
|
||||
|
||||
```go
|
||||
zoneRequest := models.NewZoneRequest("") // Invalid: empty master
|
||||
if err := zoneRequest.Validate(); err != nil {
|
||||
fmt.Printf("Zone validation failed: %v\n", err)
|
||||
// Error: "master device ID is required"
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Zone Design Guidelines
|
||||
|
||||
1. **Master Selection**: Choose the most reliable device as master
|
||||
2. **Network Quality**: Ensure all devices have stable network connections
|
||||
3. **Device Compatibility**: Verify all devices support multiroom functionality
|
||||
4. **Zone Size**: Keep zones reasonably sized (typically 2-6 devices)
|
||||
|
||||
### Error Recovery
|
||||
|
||||
```go
|
||||
// Robust zone creation with retry
|
||||
func createZoneWithRetry(client *client.Client, master string, members []string) error {
|
||||
maxRetries := 3
|
||||
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
err := client.CreateZone(master, members)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle specific errors
|
||||
if zoneErr, ok := err.(*models.ZoneError); ok {
|
||||
switch zoneErr.Reason {
|
||||
case models.ZoneErrorNetworkError:
|
||||
// Retry on network errors
|
||||
time.Sleep(time.Second * 2)
|
||||
continue
|
||||
case models.ZoneErrorDeviceNotFound:
|
||||
// Don't retry on device not found
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(time.Second * 2)
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to create zone after %d retries", maxRetries)
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
```go
|
||||
// Check if device is in zone efficiently
|
||||
func isDeviceInZone(client *client.Client) (bool, error) {
|
||||
// More efficient than getting full zone info
|
||||
return client.IsInZone()
|
||||
}
|
||||
|
||||
// Get zone member count without full member list
|
||||
func getZoneMemberCount(client *client.Client) (int, error) {
|
||||
zone, err := client.GetZone()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return zone.GetTotalDeviceCount(), nil
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### Home Automation
|
||||
|
||||
```go
|
||||
// Automatically create zones based on room groupings
|
||||
func createRoomZones() {
|
||||
livingRoomMaster := "LIVING_ROOM_MAIN"
|
||||
livingRoomMembers := []string{"LIVING_ROOM_LEFT", "LIVING_ROOM_RIGHT"}
|
||||
|
||||
kitchenMaster := "KITCHEN_MAIN"
|
||||
kitchenMembers := []string{"KITCHEN_COUNTER"}
|
||||
|
||||
// Create living room zone
|
||||
livingRoomClient := client.NewClientFromHost("192.168.1.10")
|
||||
livingRoomClient.CreateZone(livingRoomMaster, livingRoomMembers)
|
||||
|
||||
// Create kitchen zone
|
||||
kitchenClient := client.NewClientFromHost("192.168.1.20")
|
||||
kitchenClient.CreateZone(kitchenMaster, kitchenMembers)
|
||||
}
|
||||
```
|
||||
|
||||
### Party Mode
|
||||
|
||||
```go
|
||||
// Create house-wide party zone
|
||||
func enablePartyMode() {
|
||||
allDevices := []string{
|
||||
"LIVING_ROOM", "KITCHEN", "BEDROOM",
|
||||
"BATHROOM", "OFFICE", "BASEMENT",
|
||||
}
|
||||
|
||||
if len(allDevices) > 0 {
|
||||
master := allDevices[0]
|
||||
members := allDevices[1:]
|
||||
|
||||
masterClient := client.NewClientFromHost("192.168.1.10")
|
||||
err := masterClient.CreateZone(master, members)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create party zone: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disablePartyMode() {
|
||||
// Dissolve all zones
|
||||
for _, ip := range []string{"192.168.1.10", "192.168.1.11", "192.168.1.12"} {
|
||||
client := client.NewClientFromHost(ip)
|
||||
client.DissolveZone()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Music Following
|
||||
|
||||
```go
|
||||
// Move zone to follow user between rooms
|
||||
func moveZoneToRoom(currentClient, targetClient *client.Client, targetDeviceID string) error {
|
||||
// Get current zone
|
||||
zone, err := currentClient.GetZone()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add target device to zone
|
||||
err = currentClient.AddToZone(targetDeviceID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Wait for synchronization
|
||||
time.Sleep(time.Second * 2)
|
||||
|
||||
// Make target device the new master
|
||||
newZoneRequest := models.NewZoneRequest(targetDeviceID)
|
||||
for _, member := range zone.Members {
|
||||
if member.DeviceID != targetDeviceID {
|
||||
newZoneRequest.AddMember(member.DeviceID, member.IP)
|
||||
}
|
||||
}
|
||||
|
||||
return targetClient.SetZone(newZoneRequest)
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Zone Creation Fails**
|
||||
- Verify all devices are online and reachable
|
||||
- Check network connectivity between devices
|
||||
- Ensure devices support multiroom functionality
|
||||
|
||||
2. **Devices Not Synchronizing**
|
||||
- Check network quality and bandwidth
|
||||
- Verify all devices are on the same network segment
|
||||
- Try recreating the zone
|
||||
|
||||
3. **Zone Commands Timeout**
|
||||
- Increase client timeout
|
||||
- Check device responsiveness
|
||||
- Verify API endpoint availability
|
||||
|
||||
### Debug Zone Status
|
||||
|
||||
```go
|
||||
func debugZoneStatus(client *client.Client) {
|
||||
// Get comprehensive zone information
|
||||
zone, err := client.GetZone()
|
||||
if err != nil {
|
||||
fmt.Printf("Error getting zone: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Zone Debug Information:\n")
|
||||
fmt.Printf(" Master: %s\n", zone.Master)
|
||||
fmt.Printf(" Members: %d\n", len(zone.Members))
|
||||
fmt.Printf(" Total Devices: %d\n", zone.GetTotalDeviceCount())
|
||||
fmt.Printf(" Is Standalone: %t\n", zone.IsStandalone())
|
||||
|
||||
for i, member := range zone.Members {
|
||||
fmt.Printf(" Member %d: %s (IP: %s)\n",
|
||||
i+1, member.DeviceID, member.IP)
|
||||
}
|
||||
|
||||
// Check device status
|
||||
status, err := client.GetZoneStatus()
|
||||
if err == nil {
|
||||
fmt.Printf(" This Device Status: %s\n", status.String())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Validation and Testing
|
||||
|
||||
```go
|
||||
// Test zone functionality
|
||||
func testZoneOperations(client *client.Client) {
|
||||
fmt.Println("Testing zone operations...")
|
||||
|
||||
// Test 1: Get initial status
|
||||
initialZone, err := client.GetZone()
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to get initial zone: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("✅ Initial zone status: %s\n", initialZone.String())
|
||||
|
||||
// Test 2: Check capabilities
|
||||
inZone, err := client.IsInZone()
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to check zone membership: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("✅ Zone membership check: %t\n", inZone)
|
||||
|
||||
// Test 3: Get zone status
|
||||
status, err := client.GetZoneStatus()
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to get zone status: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("✅ Zone status: %s\n", status.String())
|
||||
|
||||
fmt.Println("All zone tests passed!")
|
||||
}
|
||||
```
|
||||
|
||||
## API Limitations
|
||||
|
||||
### SoundTouch API Constraints
|
||||
|
||||
1. **Zone Size**: Typically limited to 6 devices per zone
|
||||
2. **Master Role**: Only certain device types can be zone masters
|
||||
3. **Network Requirements**: All devices must be on same network
|
||||
4. **Synchronization**: Audio sync depends on network quality
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
- Zone operations may take several seconds to complete
|
||||
- WebSocket events provide real-time zone change notifications
|
||||
- IP addresses in zone configurations are optional but recommended
|
||||
- Device IDs must be valid and reachable for zone operations
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Zone management requires network access to all devices
|
||||
- No authentication required for zone operations
|
||||
- Consider network segmentation for security
|
||||
- Monitor zone changes via WebSocket events for unauthorized modifications
|
||||
|
||||
## Performance Guidelines
|
||||
|
||||
- **Batch Operations**: Group multiple zone changes when possible
|
||||
- **Error Handling**: Always implement retry logic for network operations
|
||||
- **Monitoring**: Use WebSocket events for real-time zone state tracking
|
||||
- **Validation**: Validate zone configurations before applying
|
||||
|
||||
The zone management implementation provides comprehensive multiroom control with robust error handling, validation, and real-time monitoring capabilities for production-ready applications.
|
||||
@@ -724,3 +724,121 @@ func (c *Client) post(endpoint string, payload interface{}, result interface{})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetZone gets the current multiroom zone configuration
|
||||
func (c *Client) GetZone() (*models.ZoneInfo, error) {
|
||||
var zone models.ZoneInfo
|
||||
err := c.get("/getZone", &zone)
|
||||
return &zone, err
|
||||
}
|
||||
|
||||
// SetZone configures multiroom zone settings
|
||||
func (c *Client) SetZone(zoneRequest *models.ZoneRequest) error {
|
||||
if err := zoneRequest.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid zone request: %w", err)
|
||||
}
|
||||
|
||||
return c.post("/setZone", zoneRequest, nil)
|
||||
}
|
||||
|
||||
// CreateZone creates a new multiroom zone with the specified master and members
|
||||
func (c *Client) CreateZone(masterDeviceID string, memberDeviceIDs []string) error {
|
||||
zoneRequest := models.NewZoneRequest(masterDeviceID)
|
||||
|
||||
for _, deviceID := range memberDeviceIDs {
|
||||
zoneRequest.AddMemberByDeviceID(deviceID)
|
||||
}
|
||||
|
||||
return c.SetZone(zoneRequest)
|
||||
}
|
||||
|
||||
// CreateZoneWithIPs creates a new multiroom zone with device IDs and IP addresses
|
||||
func (c *Client) CreateZoneWithIPs(masterDeviceID string, members map[string]string) error {
|
||||
zoneRequest := models.NewZoneRequest(masterDeviceID)
|
||||
|
||||
for deviceID, ipAddress := range members {
|
||||
zoneRequest.AddMember(deviceID, ipAddress)
|
||||
}
|
||||
|
||||
return c.SetZone(zoneRequest)
|
||||
}
|
||||
|
||||
// AddToZone adds a device to an existing zone
|
||||
func (c *Client) AddToZone(deviceID, ipAddress string) error {
|
||||
// Get current zone configuration
|
||||
currentZone, err := c.GetZone()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current zone: %w", err)
|
||||
}
|
||||
|
||||
// Convert to zone request and add member
|
||||
zoneRequest := currentZone.ToZoneRequest()
|
||||
zoneRequest.AddMember(deviceID, ipAddress)
|
||||
|
||||
return c.SetZone(zoneRequest)
|
||||
}
|
||||
|
||||
// RemoveFromZone removes a device from the current zone
|
||||
func (c *Client) RemoveFromZone(deviceID string) error {
|
||||
// Get current zone configuration
|
||||
currentZone, err := c.GetZone()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current zone: %w", err)
|
||||
}
|
||||
|
||||
// Convert to zone request and remove member
|
||||
zoneRequest := currentZone.ToZoneRequest()
|
||||
zoneRequest.RemoveMember(deviceID)
|
||||
|
||||
return c.SetZone(zoneRequest)
|
||||
}
|
||||
|
||||
// DissolveZone dissolves the current zone, making all devices standalone
|
||||
func (c *Client) DissolveZone() error {
|
||||
// Get current zone configuration
|
||||
currentZone, err := c.GetZone()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current zone: %w", err)
|
||||
}
|
||||
|
||||
// Create standalone configuration (master only, no members)
|
||||
zoneRequest := models.NewZoneRequest(currentZone.Master)
|
||||
|
||||
return c.SetZone(zoneRequest)
|
||||
}
|
||||
|
||||
// IsInZone checks if this device is part of a multiroom zone
|
||||
func (c *Client) IsInZone() (bool, error) {
|
||||
zone, err := c.GetZone()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return !zone.IsStandalone(), nil
|
||||
}
|
||||
|
||||
// GetZoneStatus returns the zone status for this device
|
||||
func (c *Client) GetZoneStatus() (models.ZoneStatus, error) {
|
||||
zone, err := c.GetZone()
|
||||
if err != nil {
|
||||
return models.ZoneStatusStandalone, err
|
||||
}
|
||||
|
||||
// Get device info to determine our device ID
|
||||
deviceInfo, err := c.GetDeviceInfo()
|
||||
if err != nil {
|
||||
return models.ZoneStatusStandalone, fmt.Errorf("failed to get device info: %w", err)
|
||||
}
|
||||
|
||||
return zone.GetZoneStatus(deviceInfo.DeviceID), nil
|
||||
}
|
||||
|
||||
// GetZoneMembers returns all devices in the current zone
|
||||
func (c *Client) GetZoneMembers() ([]string, error) {
|
||||
zone, err := c.GetZone()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return zone.GetAllDeviceIDs(), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/user_account/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestClient_GetZone(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
responseXML string
|
||||
responseStatus int
|
||||
expectError bool
|
||||
expectedMaster string
|
||||
expectedMembers int
|
||||
}{
|
||||
{
|
||||
name: "Standalone device",
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH"></zone>`,
|
||||
responseStatus: http.StatusOK,
|
||||
expectError: false,
|
||||
expectedMaster: "ABCD1234EFGH",
|
||||
expectedMembers: 0,
|
||||
},
|
||||
{
|
||||
name: "Zone with members",
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.168.1.11">EFGH5678IJKL</member>
|
||||
<member ipaddress="192.168.1.12">IJKL9012MNOP</member>
|
||||
</zone>`,
|
||||
responseStatus: http.StatusOK,
|
||||
expectError: false,
|
||||
expectedMaster: "ABCD1234EFGH",
|
||||
expectedMembers: 2,
|
||||
},
|
||||
{
|
||||
name: "Server error",
|
||||
responseXML: `<error>Server Error</error>`,
|
||||
responseStatus: http.StatusInternalServerError,
|
||||
expectError: true,
|
||||
expectedMaster: "",
|
||||
expectedMembers: 0,
|
||||
},
|
||||
{
|
||||
name: "Device not found",
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<errors deviceID="ABCD1234EFGH">
|
||||
<error value="7" name="DEVICE_NOT_FOUND_ERROR">Device not found</error>
|
||||
</errors>`,
|
||||
responseStatus: http.StatusNotFound,
|
||||
expectError: true,
|
||||
expectedMaster: "",
|
||||
expectedMembers: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/getZone" {
|
||||
t.Errorf("Expected path /getZone, got %s", r.URL.Path)
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("Expected GET method, got %s", r.Method)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(tt.responseStatus)
|
||||
w.Write([]byte(tt.responseXML))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
zone, err := client.GetZone()
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Error("Expected error, but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if zone.Master != tt.expectedMaster {
|
||||
t.Errorf("Expected master %s, got %s", tt.expectedMaster, zone.Master)
|
||||
}
|
||||
|
||||
if len(zone.Members) != tt.expectedMembers {
|
||||
t.Errorf("Expected %d members, got %d", tt.expectedMembers, len(zone.Members))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_SetZone(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
zoneRequest *models.ZoneRequest
|
||||
responseStatus int
|
||||
expectError bool
|
||||
errorMessage string
|
||||
}{
|
||||
{
|
||||
name: "Valid zone request",
|
||||
zoneRequest: func() *models.ZoneRequest {
|
||||
zr := models.NewZoneRequest("ABCD1234EFGH")
|
||||
zr.AddMember("EFGH5678IJKL", "192.168.1.11")
|
||||
return zr
|
||||
}(),
|
||||
responseStatus: http.StatusOK,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Standalone zone request",
|
||||
zoneRequest: models.NewZoneRequest("ABCD1234EFGH"),
|
||||
responseStatus: http.StatusOK,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid zone request - empty master",
|
||||
zoneRequest: &models.ZoneRequest{},
|
||||
expectError: true,
|
||||
errorMessage: "invalid zone request: master device ID is required",
|
||||
},
|
||||
{
|
||||
name: "Invalid zone request - duplicate device",
|
||||
zoneRequest: func() *models.ZoneRequest {
|
||||
zr := models.NewZoneRequest("ABCD1234EFGH")
|
||||
zr.AddMember("ABCD1234EFGH", "192.168.1.10") // Same as master
|
||||
return zr
|
||||
}(),
|
||||
expectError: true,
|
||||
errorMessage: "invalid zone request: duplicate device ID found: ABCD1234EFGH",
|
||||
},
|
||||
{
|
||||
name: "Server error response",
|
||||
zoneRequest: models.NewZoneRequest("ABCD1234EFGH"),
|
||||
responseStatus: http.StatusInternalServerError,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/setZone" {
|
||||
t.Errorf("Expected path /setZone, got %s", r.URL.Path)
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("Expected POST method, got %s", r.Method)
|
||||
}
|
||||
|
||||
// Check Content-Type
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if contentType != "application/xml" {
|
||||
t.Errorf("Expected Content-Type application/xml, got %s", contentType)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(tt.responseStatus)
|
||||
if tt.responseStatus != http.StatusOK {
|
||||
w.Write([]byte(`<error>Server Error</error>`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
err := client.SetZone(tt.zoneRequest)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Error("Expected error, but got none")
|
||||
} else if tt.errorMessage != "" && err.Error() != tt.errorMessage {
|
||||
t.Errorf("Expected error message '%s', got '%s'", tt.errorMessage, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CreateZone(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/setZone" {
|
||||
t.Errorf("Expected path /setZone, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
|
||||
masterDeviceID := "ABCD1234EFGH"
|
||||
memberDeviceIDs := []string{"EFGH5678IJKL", "IJKL9012MNOP"}
|
||||
|
||||
err := client.CreateZone(masterDeviceID, memberDeviceIDs)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CreateZoneWithIPs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/setZone" {
|
||||
t.Errorf("Expected path /setZone, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
|
||||
masterDeviceID := "ABCD1234EFGH"
|
||||
members := map[string]string{
|
||||
"EFGH5678IJKL": "192.168.1.11",
|
||||
"IJKL9012MNOP": "192.168.1.12",
|
||||
}
|
||||
|
||||
err := client.CreateZoneWithIPs(masterDeviceID, members)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_AddToZone(t *testing.T) {
|
||||
getZoneCalled := false
|
||||
setZoneCalled := false
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
if r.URL.Path == "/getZone" && r.Method == http.MethodGet {
|
||||
getZoneCalled = true
|
||||
// Return existing zone
|
||||
response := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.168.1.11">EFGH5678IJKL</member>
|
||||
</zone>`
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(response))
|
||||
} else if r.URL.Path == "/setZone" && r.Method == http.MethodPost {
|
||||
setZoneCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
|
||||
err := client.AddToZone("IJKL9012MNOP", "192.168.1.12")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
}
|
||||
|
||||
if !getZoneCalled {
|
||||
t.Error("Expected GetZone to be called")
|
||||
}
|
||||
if !setZoneCalled {
|
||||
t.Error("Expected SetZone to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_RemoveFromZone(t *testing.T) {
|
||||
getZoneCalled := false
|
||||
setZoneCalled := false
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
if r.URL.Path == "/getZone" && r.Method == http.MethodGet {
|
||||
getZoneCalled = true
|
||||
// Return existing zone with members
|
||||
response := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.168.1.11">EFGH5678IJKL</member>
|
||||
<member ipaddress="192.168.1.12">IJKL9012MNOP</member>
|
||||
</zone>`
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(response))
|
||||
} else if r.URL.Path == "/setZone" && r.Method == http.MethodPost {
|
||||
setZoneCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
|
||||
err := client.RemoveFromZone("EFGH5678IJKL")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
}
|
||||
|
||||
if !getZoneCalled {
|
||||
t.Error("Expected GetZone to be called")
|
||||
}
|
||||
if !setZoneCalled {
|
||||
t.Error("Expected SetZone to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_DissolveZone(t *testing.T) {
|
||||
getZoneCalled := false
|
||||
setZoneCalled := false
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
if r.URL.Path == "/getZone" && r.Method == http.MethodGet {
|
||||
getZoneCalled = true
|
||||
// Return existing zone with members
|
||||
response := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.168.1.11">EFGH5678IJKL</member>
|
||||
<member ipaddress="192.168.1.12">IJKL9012MNOP</member>
|
||||
</zone>`
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(response))
|
||||
} else if r.URL.Path == "/setZone" && r.Method == http.MethodPost {
|
||||
setZoneCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
|
||||
err := client.DissolveZone()
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
}
|
||||
|
||||
if !getZoneCalled {
|
||||
t.Error("Expected GetZone to be called")
|
||||
}
|
||||
if !setZoneCalled {
|
||||
t.Error("Expected SetZone to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_IsInZone(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
responseXML string
|
||||
expectedResult bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "Standalone device",
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH"></zone>`,
|
||||
expectedResult: false,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Device in zone",
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.168.1.11">EFGH5678IJKL</member>
|
||||
</zone>`,
|
||||
expectedResult: true,
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/getZone" {
|
||||
t.Errorf("Expected path /getZone, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(tt.responseXML))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
result, err := client.IsInZone()
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Error("Expected error, but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if result != tt.expectedResult {
|
||||
t.Errorf("Expected result %t, got %t", tt.expectedResult, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetZoneStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deviceInfoXML string
|
||||
zoneXML string
|
||||
expectedStatus models.ZoneStatus
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "Standalone device",
|
||||
deviceInfoXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<info deviceID="ABCD1234EFGH">
|
||||
<name>Living Room</name>
|
||||
<type>SoundTouch 20</type>
|
||||
</info>`,
|
||||
zoneXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH"></zone>`,
|
||||
expectedStatus: models.ZoneStatusStandalone,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Zone master",
|
||||
deviceInfoXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<info deviceID="ABCD1234EFGH">
|
||||
<name>Living Room</name>
|
||||
<type>SoundTouch 20</type>
|
||||
</info>`,
|
||||
zoneXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.168.1.11">EFGH5678IJKL</member>
|
||||
</zone>`,
|
||||
expectedStatus: models.ZoneStatusMaster,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Zone member",
|
||||
deviceInfoXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<info deviceID="EFGH5678IJKL">
|
||||
<name>Kitchen</name>
|
||||
<type>SoundTouch 10</type>
|
||||
</info>`,
|
||||
zoneXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.168.1.11">EFGH5678IJKL</member>
|
||||
</zone>`,
|
||||
expectedStatus: models.ZoneStatusSlave,
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
if r.URL.Path == "/getZone" {
|
||||
w.Write([]byte(tt.zoneXML))
|
||||
} else if r.URL.Path == "/info" {
|
||||
w.Write([]byte(tt.deviceInfoXML))
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
status, err := client.GetZoneStatus()
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Error("Expected error, but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if status != tt.expectedStatus {
|
||||
t.Errorf("Expected status %v, got %v", tt.expectedStatus, status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_GetZoneMembers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
responseXML string
|
||||
expectedMembers []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "Standalone device",
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH"></zone>`,
|
||||
expectedMembers: []string{"ABCD1234EFGH"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Zone with members",
|
||||
responseXML: `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.168.1.11">EFGH5678IJKL</member>
|
||||
<member ipaddress="192.168.1.12">IJKL9012MNOP</member>
|
||||
</zone>`,
|
||||
expectedMembers: []string{"ABCD1234EFGH", "EFGH5678IJKL", "IJKL9012MNOP"},
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/getZone" {
|
||||
t.Errorf("Expected path /getZone, got %s", r.URL.Path)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(tt.responseXML))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
members, err := client.GetZoneMembers()
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Error("Expected error, but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(members) != len(tt.expectedMembers) {
|
||||
t.Errorf("Expected %d members, got %d", len(tt.expectedMembers), len(members))
|
||||
return
|
||||
}
|
||||
|
||||
for i, expectedMember := range tt.expectedMembers {
|
||||
if members[i] != expectedMember {
|
||||
t.Errorf("Expected member %s at index %d, got %s", expectedMember, i, members[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Zone_ErrorHandling(t *testing.T) {
|
||||
t.Run("GetZone network error", func(t *testing.T) {
|
||||
client := NewClientFromHost("invalid.host:9999")
|
||||
client.timeout = 100 * time.Millisecond
|
||||
|
||||
_, err := client.GetZone()
|
||||
if err == nil {
|
||||
t.Error("Expected network error, but got none")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SetZone network error", func(t *testing.T) {
|
||||
client := NewClientFromHost("invalid.host:9999")
|
||||
client.timeout = 100 * time.Millisecond
|
||||
|
||||
zr := models.NewZoneRequest("DEVICE123")
|
||||
err := client.SetZone(zr)
|
||||
if err == nil {
|
||||
t.Error("Expected network error, but got none")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AddToZone - GetZone fails", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/getZone" {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`<error>Server Error</error>`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
err := client.AddToZone("DEVICE456", "192.168.1.10")
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error when GetZone fails, but got none")
|
||||
}
|
||||
|
||||
expectedErrorPrefix := "failed to get current zone:"
|
||||
if !strings.Contains(err.Error(), expectedErrorPrefix) {
|
||||
t.Errorf("Expected error to contain '%s', got: %v", expectedErrorPrefix, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkClient_GetZone(b *testing.B) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response := `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<zone master="ABCD1234EFGH">
|
||||
<member ipaddress="192.168.1.11">EFGH5678IJKL</member>
|
||||
<member ipaddress="192.168.1.12">IJKL9012MNOP</member>
|
||||
</zone>`
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := client.GetZone()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkClient_SetZone(b *testing.B) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := createTestClient(server.URL)
|
||||
zoneRequest := models.NewZoneRequest("ABCD1234EFGH")
|
||||
zoneRequest.AddMember("EFGH5678IJKL", "192.168.1.11")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
err := client.SetZone(zoneRequest)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ZoneInfo represents the response from GET /getZone endpoint
|
||||
type ZoneInfo struct {
|
||||
XMLName xml.Name `xml:"zone"`
|
||||
Master string `xml:"master,attr"`
|
||||
Members []Member `xml:"member"`
|
||||
}
|
||||
|
||||
// Member represents a device member in a multiroom zone
|
||||
type Member struct {
|
||||
XMLName xml.Name `xml:"member"`
|
||||
DeviceID string `xml:",chardata"`
|
||||
IP string `xml:"ipaddress,attr"`
|
||||
}
|
||||
|
||||
// ZoneRequest represents the request for POST /setZone endpoint
|
||||
type ZoneRequest struct {
|
||||
XMLName xml.Name `xml:"zone"`
|
||||
Master string `xml:"master,attr"`
|
||||
Members []MemberEntry `xml:"member"`
|
||||
}
|
||||
|
||||
// MemberEntry represents a member entry in zone configuration requests
|
||||
type MemberEntry struct {
|
||||
XMLName xml.Name `xml:"member"`
|
||||
DeviceID string `xml:",chardata"`
|
||||
IP string `xml:"ipaddress,attr,omitempty"`
|
||||
}
|
||||
|
||||
// ZoneStatus represents possible zone states
|
||||
type ZoneStatus string
|
||||
|
||||
const (
|
||||
ZoneStatusStandalone ZoneStatus = "STANDALONE"
|
||||
ZoneStatusMaster ZoneStatus = "MASTER"
|
||||
ZoneStatusSlave ZoneStatus = "SLAVE"
|
||||
)
|
||||
|
||||
// String returns a human-readable string representation
|
||||
func (zs ZoneStatus) String() string {
|
||||
switch zs {
|
||||
case ZoneStatusStandalone:
|
||||
return "Standalone"
|
||||
case ZoneStatusMaster:
|
||||
return "Zone Master"
|
||||
case ZoneStatusSlave:
|
||||
return "Zone Member"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// NewZoneRequest creates a new zone configuration request
|
||||
func NewZoneRequest(masterDeviceID string) *ZoneRequest {
|
||||
return &ZoneRequest{
|
||||
Master: masterDeviceID,
|
||||
Members: []MemberEntry{},
|
||||
}
|
||||
}
|
||||
|
||||
// AddMember adds a device to the zone configuration
|
||||
func (zr *ZoneRequest) AddMember(deviceID, ipAddress string) {
|
||||
member := MemberEntry{
|
||||
DeviceID: deviceID,
|
||||
IP: ipAddress,
|
||||
}
|
||||
zr.Members = append(zr.Members, member)
|
||||
}
|
||||
|
||||
// AddMemberByDeviceID adds a device to the zone by device ID only
|
||||
func (zr *ZoneRequest) AddMemberByDeviceID(deviceID string) {
|
||||
member := MemberEntry{
|
||||
DeviceID: deviceID,
|
||||
}
|
||||
zr.Members = append(zr.Members, member)
|
||||
}
|
||||
|
||||
// RemoveMember removes a device from the zone configuration
|
||||
func (zr *ZoneRequest) RemoveMember(deviceID string) {
|
||||
for i, member := range zr.Members {
|
||||
if member.DeviceID == deviceID {
|
||||
zr.Members = append(zr.Members[:i], zr.Members[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ClearMembers removes all members from the zone (creates standalone configuration)
|
||||
func (zr *ZoneRequest) ClearMembers() {
|
||||
zr.Members = []MemberEntry{}
|
||||
}
|
||||
|
||||
// HasMember checks if a device is in the zone configuration
|
||||
func (zr *ZoneRequest) HasMember(deviceID string) bool {
|
||||
for _, member := range zr.Members {
|
||||
if member.DeviceID == deviceID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetMemberCount returns the number of members in the zone
|
||||
func (zr *ZoneRequest) GetMemberCount() int {
|
||||
return len(zr.Members)
|
||||
}
|
||||
|
||||
// Validate validates the zone request
|
||||
func (zr *ZoneRequest) Validate() error {
|
||||
if zr.Master == "" {
|
||||
return fmt.Errorf("master device ID is required")
|
||||
}
|
||||
|
||||
// Check for duplicate device IDs
|
||||
seen := make(map[string]bool)
|
||||
seen[zr.Master] = true
|
||||
|
||||
for _, member := range zr.Members {
|
||||
if member.DeviceID == "" {
|
||||
return fmt.Errorf("member device ID cannot be empty")
|
||||
}
|
||||
|
||||
if seen[member.DeviceID] {
|
||||
return fmt.Errorf("duplicate device ID found: %s", member.DeviceID)
|
||||
}
|
||||
seen[member.DeviceID] = true
|
||||
|
||||
// Validate IP address if provided
|
||||
if member.IP != "" {
|
||||
if net.ParseIP(member.IP) == nil {
|
||||
return fmt.Errorf("invalid IP address for device %s: %s", member.DeviceID, member.IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsStandalone returns true if this is a standalone (single device) configuration
|
||||
func (zi *ZoneInfo) IsStandalone() bool {
|
||||
return len(zi.Members) == 0
|
||||
}
|
||||
|
||||
// IsMaster returns true if the given device ID is the zone master
|
||||
func (zi *ZoneInfo) IsMaster(deviceID string) bool {
|
||||
return zi.Master == deviceID
|
||||
}
|
||||
|
||||
// IsMember returns true if the given device ID is a zone member (not master)
|
||||
func (zi *ZoneInfo) IsMember(deviceID string) bool {
|
||||
for _, member := range zi.Members {
|
||||
if member.DeviceID == deviceID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsInZone returns true if the given device ID is in the zone (master or member)
|
||||
func (zi *ZoneInfo) IsInZone(deviceID string) bool {
|
||||
return zi.IsMaster(deviceID) || zi.IsMember(deviceID)
|
||||
}
|
||||
|
||||
// GetMemberByDeviceID returns the member with the given device ID
|
||||
func (zi *ZoneInfo) GetMemberByDeviceID(deviceID string) (*Member, bool) {
|
||||
for _, member := range zi.Members {
|
||||
if member.DeviceID == deviceID {
|
||||
return &member, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// GetMemberByIP returns the member with the given IP address
|
||||
func (zi *ZoneInfo) GetMemberByIP(ipAddress string) (*Member, bool) {
|
||||
for _, member := range zi.Members {
|
||||
if member.IP == ipAddress {
|
||||
return &member, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// GetAllDeviceIDs returns all device IDs in the zone (master + members)
|
||||
func (zi *ZoneInfo) GetAllDeviceIDs() []string {
|
||||
devices := []string{zi.Master}
|
||||
for _, member := range zi.Members {
|
||||
devices = append(devices, member.DeviceID)
|
||||
}
|
||||
return devices
|
||||
}
|
||||
|
||||
// GetTotalDeviceCount returns the total number of devices in the zone
|
||||
func (zi *ZoneInfo) GetTotalDeviceCount() int {
|
||||
return 1 + len(zi.Members) // Master + members
|
||||
}
|
||||
|
||||
// GetZoneStatus returns the zone status for a given device ID
|
||||
func (zi *ZoneInfo) GetZoneStatus(deviceID string) ZoneStatus {
|
||||
if zi.IsMaster(deviceID) {
|
||||
if zi.IsStandalone() {
|
||||
return ZoneStatusStandalone
|
||||
}
|
||||
return ZoneStatusMaster
|
||||
}
|
||||
if zi.IsMember(deviceID) {
|
||||
return ZoneStatusSlave
|
||||
}
|
||||
return ZoneStatusStandalone // Device not in zone
|
||||
}
|
||||
|
||||
// String returns a human-readable string representation of the zone
|
||||
func (zi *ZoneInfo) String() string {
|
||||
if zi.IsStandalone() {
|
||||
return fmt.Sprintf("Standalone device: %s", zi.Master)
|
||||
}
|
||||
|
||||
var memberIDs []string
|
||||
for _, member := range zi.Members {
|
||||
memberIDs = append(memberIDs, member.DeviceID)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Zone Master: %s, Members: [%s] (%d total devices)",
|
||||
zi.Master, strings.Join(memberIDs, ", "), zi.GetTotalDeviceCount())
|
||||
}
|
||||
|
||||
// ToZoneRequest converts ZoneInfo to a ZoneRequest for modification
|
||||
func (zi *ZoneInfo) ToZoneRequest() *ZoneRequest {
|
||||
request := NewZoneRequest(zi.Master)
|
||||
for _, member := range zi.Members {
|
||||
request.AddMember(member.DeviceID, member.IP)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
// ZoneOperation represents different types of zone operations
|
||||
type ZoneOperation string
|
||||
|
||||
const (
|
||||
ZoneOpCreate ZoneOperation = "CREATE"
|
||||
ZoneOpModify ZoneOperation = "MODIFY"
|
||||
ZoneOpAddMember ZoneOperation = "ADD_MEMBER"
|
||||
ZoneOpRemove ZoneOperation = "REMOVE_MEMBER"
|
||||
ZoneOpDissolve ZoneOperation = "DISSOLVE"
|
||||
)
|
||||
|
||||
// String returns a human-readable string representation
|
||||
func (zo ZoneOperation) String() string {
|
||||
switch zo {
|
||||
case ZoneOpCreate:
|
||||
return "Create Zone"
|
||||
case ZoneOpModify:
|
||||
return "Modify Zone"
|
||||
case ZoneOpAddMember:
|
||||
return "Add Member"
|
||||
case ZoneOpRemove:
|
||||
return "Remove Member"
|
||||
case ZoneOpDissolve:
|
||||
return "Dissolve Zone"
|
||||
default:
|
||||
return "Unknown Operation"
|
||||
}
|
||||
}
|
||||
|
||||
// ZoneBuilder provides a fluent interface for building zone configurations
|
||||
type ZoneBuilder struct {
|
||||
request *ZoneRequest
|
||||
}
|
||||
|
||||
// NewZoneBuilder creates a new zone builder with the specified master device
|
||||
func NewZoneBuilder(masterDeviceID string) *ZoneBuilder {
|
||||
return &ZoneBuilder{
|
||||
request: NewZoneRequest(masterDeviceID),
|
||||
}
|
||||
}
|
||||
|
||||
// WithMember adds a member to the zone configuration
|
||||
func (zb *ZoneBuilder) WithMember(deviceID, ipAddress string) *ZoneBuilder {
|
||||
zb.request.AddMember(deviceID, ipAddress)
|
||||
return zb
|
||||
}
|
||||
|
||||
// WithMemberByDeviceID adds a member by device ID only
|
||||
func (zb *ZoneBuilder) WithMemberByDeviceID(deviceID string) *ZoneBuilder {
|
||||
zb.request.AddMemberByDeviceID(deviceID)
|
||||
return zb
|
||||
}
|
||||
|
||||
// Build returns the constructed zone request
|
||||
func (zb *ZoneBuilder) Build() (*ZoneRequest, error) {
|
||||
if err := zb.request.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return zb.request, nil
|
||||
}
|
||||
|
||||
// ZoneError represents zone-specific errors
|
||||
type ZoneError struct {
|
||||
Operation ZoneOperation
|
||||
DeviceID string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (ze *ZoneError) Error() string {
|
||||
if ze.DeviceID != "" {
|
||||
return fmt.Sprintf("zone %s failed for device %s: %s",
|
||||
ze.Operation.String(), ze.DeviceID, ze.Reason)
|
||||
}
|
||||
return fmt.Sprintf("zone %s failed: %s", ze.Operation.String(), ze.Reason)
|
||||
}
|
||||
|
||||
// NewZoneError creates a new zone error
|
||||
func NewZoneError(op ZoneOperation, deviceID, reason string) *ZoneError {
|
||||
return &ZoneError{
|
||||
Operation: op,
|
||||
DeviceID: deviceID,
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
// Common zone error reasons
|
||||
const (
|
||||
ZoneErrorDeviceNotFound = "device not found"
|
||||
ZoneErrorDeviceOffline = "device offline"
|
||||
ZoneErrorIncompatible = "incompatible device type"
|
||||
ZoneErrorAlreadyInZone = "device already in zone"
|
||||
ZoneErrorNotInZone = "device not in zone"
|
||||
ZoneErrorMasterRequired = "master device required"
|
||||
ZoneErrorNetworkError = "network communication error"
|
||||
ZoneErrorUnsupported = "operation not supported by device"
|
||||
ZoneErrorMaxMembersReached = "maximum zone members reached"
|
||||
)
|
||||
|
||||
// ZoneCapabilities represents zone-related capabilities of a device
|
||||
type ZoneCapabilities struct {
|
||||
CanBeMaster bool `json:"canBeMaster"`
|
||||
CanBeMember bool `json:"canBeMember"`
|
||||
MaxZoneMembers int `json:"maxZoneMembers"`
|
||||
SupportsMultiroom bool `json:"supportsMultiroom"`
|
||||
}
|
||||
|
||||
// DefaultZoneCapabilities returns default zone capabilities
|
||||
func DefaultZoneCapabilities() ZoneCapabilities {
|
||||
return ZoneCapabilities{
|
||||
CanBeMaster: true,
|
||||
CanBeMember: true,
|
||||
MaxZoneMembers: 6, // Common SoundTouch limit
|
||||
SupportsMultiroom: true,
|
||||
}
|
||||
}
|
||||
|
||||
// CanCreateZone returns true if the device can create zones
|
||||
func (zc *ZoneCapabilities) CanCreateZone() bool {
|
||||
return zc.SupportsMultiroom && zc.CanBeMaster
|
||||
}
|
||||
|
||||
// CanJoinZone returns true if the device can join zones
|
||||
func (zc *ZoneCapabilities) CanJoinZone() bool {
|
||||
return zc.SupportsMultiroom && zc.CanBeMember
|
||||
}
|
||||
@@ -0,0 +1,823 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewZoneRequest(t *testing.T) {
|
||||
masterID := "ABCD1234"
|
||||
zr := NewZoneRequest(masterID)
|
||||
|
||||
if zr.Master != masterID {
|
||||
t.Errorf("Expected master %s, got %s", masterID, zr.Master)
|
||||
}
|
||||
|
||||
if len(zr.Members) != 0 {
|
||||
t.Errorf("Expected 0 members, got %d", len(zr.Members))
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_AddMember(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
|
||||
zr.AddMember("DEVICE456", "192.168.1.10")
|
||||
zr.AddMember("DEVICE789", "192.168.1.11")
|
||||
|
||||
if len(zr.Members) != 2 {
|
||||
t.Errorf("Expected 2 members, got %d", len(zr.Members))
|
||||
}
|
||||
|
||||
if zr.Members[0].DeviceID != "DEVICE456" {
|
||||
t.Errorf("Expected first member DEVICE456, got %s", zr.Members[0].DeviceID)
|
||||
}
|
||||
|
||||
if zr.Members[0].IP != "192.168.1.10" {
|
||||
t.Errorf("Expected first member IP 192.168.1.10, got %s", zr.Members[0].IP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_AddMemberByDeviceID(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
|
||||
zr.AddMemberByDeviceID("DEVICE456")
|
||||
|
||||
if len(zr.Members) != 1 {
|
||||
t.Errorf("Expected 1 member, got %d", len(zr.Members))
|
||||
}
|
||||
|
||||
if zr.Members[0].DeviceID != "DEVICE456" {
|
||||
t.Errorf("Expected member DEVICE456, got %s", zr.Members[0].DeviceID)
|
||||
}
|
||||
|
||||
if zr.Members[0].IP != "" {
|
||||
t.Errorf("Expected empty IP, got %s", zr.Members[0].IP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_RemoveMember(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("DEVICE456", "192.168.1.10")
|
||||
zr.AddMember("DEVICE789", "192.168.1.11")
|
||||
zr.AddMember("DEVICEABC", "192.168.1.12")
|
||||
|
||||
// Remove middle member
|
||||
zr.RemoveMember("DEVICE789")
|
||||
|
||||
if len(zr.Members) != 2 {
|
||||
t.Errorf("Expected 2 members after removal, got %d", len(zr.Members))
|
||||
}
|
||||
|
||||
// Check that the correct member was removed
|
||||
for _, member := range zr.Members {
|
||||
if member.DeviceID == "DEVICE789" {
|
||||
t.Error("DEVICE789 should have been removed")
|
||||
}
|
||||
}
|
||||
|
||||
// Remove non-existent member (should not change anything)
|
||||
zr.RemoveMember("NONEXISTENT")
|
||||
if len(zr.Members) != 2 {
|
||||
t.Errorf("Expected 2 members after removing non-existent, got %d", len(zr.Members))
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_ClearMembers(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("DEVICE456", "192.168.1.10")
|
||||
zr.AddMember("DEVICE789", "192.168.1.11")
|
||||
|
||||
zr.ClearMembers()
|
||||
|
||||
if len(zr.Members) != 0 {
|
||||
t.Errorf("Expected 0 members after clear, got %d", len(zr.Members))
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_HasMember(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("DEVICE456", "192.168.1.10")
|
||||
|
||||
if !zr.HasMember("DEVICE456") {
|
||||
t.Error("Expected HasMember to return true for DEVICE456")
|
||||
}
|
||||
|
||||
if zr.HasMember("NONEXISTENT") {
|
||||
t.Error("Expected HasMember to return false for non-existent device")
|
||||
}
|
||||
|
||||
if zr.HasMember("MASTER123") {
|
||||
t.Error("Expected HasMember to return false for master device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_GetMemberCount(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
|
||||
if zr.GetMemberCount() != 0 {
|
||||
t.Errorf("Expected 0 members initially, got %d", zr.GetMemberCount())
|
||||
}
|
||||
|
||||
zr.AddMember("DEVICE456", "192.168.1.10")
|
||||
if zr.GetMemberCount() != 1 {
|
||||
t.Errorf("Expected 1 member, got %d", zr.GetMemberCount())
|
||||
}
|
||||
|
||||
zr.AddMember("DEVICE789", "192.168.1.11")
|
||||
if zr.GetMemberCount() != 2 {
|
||||
t.Errorf("Expected 2 members, got %d", zr.GetMemberCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRequest_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func() *ZoneRequest
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "Valid zone request",
|
||||
setupFunc: func() *ZoneRequest {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("DEVICE456", "192.168.1.10")
|
||||
return zr
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Valid zone request without IP",
|
||||
setupFunc: func() *ZoneRequest {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMemberByDeviceID("DEVICE456")
|
||||
return zr
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Empty master device ID",
|
||||
setupFunc: func() *ZoneRequest {
|
||||
zr := &ZoneRequest{}
|
||||
return zr
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "master device ID is required",
|
||||
},
|
||||
{
|
||||
name: "Empty member device ID",
|
||||
setupFunc: func() *ZoneRequest {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.Members = append(zr.Members, MemberEntry{DeviceID: ""})
|
||||
return zr
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "member device ID cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "Duplicate member device ID",
|
||||
setupFunc: func() *ZoneRequest {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("DEVICE456", "192.168.1.10")
|
||||
zr.AddMember("DEVICE456", "192.168.1.11")
|
||||
return zr
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "duplicate device ID found: DEVICE456",
|
||||
},
|
||||
{
|
||||
name: "Master device ID as member",
|
||||
setupFunc: func() *ZoneRequest {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("MASTER123", "192.168.1.10")
|
||||
return zr
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "duplicate device ID found: MASTER123",
|
||||
},
|
||||
{
|
||||
name: "Invalid IP address",
|
||||
setupFunc: func() *ZoneRequest {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("DEVICE456", "invalid.ip.address")
|
||||
return zr
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "invalid IP address for device DEVICE456: invalid.ip.address",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
zr := tt.setupFunc()
|
||||
err := zr.Validate()
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error, but got none")
|
||||
} else if err.Error() != tt.errorMsg {
|
||||
t.Errorf("Expected error '%s', got '%s'", tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_IsStandalone(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{},
|
||||
}
|
||||
|
||||
if !zi.IsStandalone() {
|
||||
t.Error("Expected standalone zone to return true")
|
||||
}
|
||||
|
||||
zi.Members = append(zi.Members, Member{DeviceID: "DEVICE456"})
|
||||
if zi.IsStandalone() {
|
||||
t.Error("Expected zone with members to return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_IsMaster(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456"},
|
||||
},
|
||||
}
|
||||
|
||||
if !zi.IsMaster("MASTER123") {
|
||||
t.Error("Expected IsMaster to return true for master device")
|
||||
}
|
||||
|
||||
if zi.IsMaster("DEVICE456") {
|
||||
t.Error("Expected IsMaster to return false for member device")
|
||||
}
|
||||
|
||||
if zi.IsMaster("NONEXISTENT") {
|
||||
t.Error("Expected IsMaster to return false for non-existent device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_IsMember(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456"},
|
||||
{DeviceID: "DEVICE789"},
|
||||
},
|
||||
}
|
||||
|
||||
if !zi.IsMember("DEVICE456") {
|
||||
t.Error("Expected IsMember to return true for member device")
|
||||
}
|
||||
|
||||
if zi.IsMember("MASTER123") {
|
||||
t.Error("Expected IsMember to return false for master device")
|
||||
}
|
||||
|
||||
if zi.IsMember("NONEXISTENT") {
|
||||
t.Error("Expected IsMember to return false for non-existent device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_IsInZone(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456"},
|
||||
},
|
||||
}
|
||||
|
||||
if !zi.IsInZone("MASTER123") {
|
||||
t.Error("Expected IsInZone to return true for master device")
|
||||
}
|
||||
|
||||
if !zi.IsInZone("DEVICE456") {
|
||||
t.Error("Expected IsInZone to return true for member device")
|
||||
}
|
||||
|
||||
if zi.IsInZone("NONEXISTENT") {
|
||||
t.Error("Expected IsInZone to return false for non-existent device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_GetMemberByDeviceID(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456", IP: "192.168.1.10"},
|
||||
{DeviceID: "DEVICE789", IP: "192.168.1.11"},
|
||||
},
|
||||
}
|
||||
|
||||
member, found := zi.GetMemberByDeviceID("DEVICE456")
|
||||
if !found {
|
||||
t.Error("Expected to find member DEVICE456")
|
||||
}
|
||||
if member.IP != "192.168.1.10" {
|
||||
t.Errorf("Expected IP 192.168.1.10, got %s", member.IP)
|
||||
}
|
||||
|
||||
_, found = zi.GetMemberByDeviceID("NONEXISTENT")
|
||||
if found {
|
||||
t.Error("Expected not to find non-existent member")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_GetMemberByIP(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456", IP: "192.168.1.10"},
|
||||
{DeviceID: "DEVICE789", IP: "192.168.1.11"},
|
||||
},
|
||||
}
|
||||
|
||||
member, found := zi.GetMemberByIP("192.168.1.10")
|
||||
if !found {
|
||||
t.Error("Expected to find member by IP 192.168.1.10")
|
||||
}
|
||||
if member.DeviceID != "DEVICE456" {
|
||||
t.Errorf("Expected device ID DEVICE456, got %s", member.DeviceID)
|
||||
}
|
||||
|
||||
_, found = zi.GetMemberByIP("192.168.1.99")
|
||||
if found {
|
||||
t.Error("Expected not to find member with non-existent IP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_GetAllDeviceIDs(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456"},
|
||||
{DeviceID: "DEVICE789"},
|
||||
},
|
||||
}
|
||||
|
||||
deviceIDs := zi.GetAllDeviceIDs()
|
||||
expected := []string{"MASTER123", "DEVICE456", "DEVICE789"}
|
||||
|
||||
if len(deviceIDs) != len(expected) {
|
||||
t.Errorf("Expected %d device IDs, got %d", len(expected), len(deviceIDs))
|
||||
}
|
||||
|
||||
for i, expectedID := range expected {
|
||||
if deviceIDs[i] != expectedID {
|
||||
t.Errorf("Expected device ID %s at index %d, got %s", expectedID, i, deviceIDs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_GetTotalDeviceCount(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456"},
|
||||
{DeviceID: "DEVICE789"},
|
||||
},
|
||||
}
|
||||
|
||||
count := zi.GetTotalDeviceCount()
|
||||
if count != 3 {
|
||||
t.Errorf("Expected 3 devices, got %d", count)
|
||||
}
|
||||
|
||||
// Test standalone
|
||||
zi.Members = []Member{}
|
||||
count = zi.GetTotalDeviceCount()
|
||||
if count != 1 {
|
||||
t.Errorf("Expected 1 device for standalone, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_GetZoneStatus(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456"},
|
||||
},
|
||||
}
|
||||
|
||||
// Test master device in zone
|
||||
status := zi.GetZoneStatus("MASTER123")
|
||||
if status != ZoneStatusMaster {
|
||||
t.Errorf("Expected ZoneStatusMaster, got %v", status)
|
||||
}
|
||||
|
||||
// Test member device
|
||||
status = zi.GetZoneStatus("DEVICE456")
|
||||
if status != ZoneStatusSlave {
|
||||
t.Errorf("Expected ZoneStatusSlave, got %v", status)
|
||||
}
|
||||
|
||||
// Test device not in zone
|
||||
status = zi.GetZoneStatus("NONEXISTENT")
|
||||
if status != ZoneStatusStandalone {
|
||||
t.Errorf("Expected ZoneStatusStandalone, got %v", status)
|
||||
}
|
||||
|
||||
// Test standalone master
|
||||
zi.Members = []Member{}
|
||||
status = zi.GetZoneStatus("MASTER123")
|
||||
if status != ZoneStatusStandalone {
|
||||
t.Errorf("Expected ZoneStatusStandalone for standalone master, got %v", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneStatus_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
status ZoneStatus
|
||||
expected string
|
||||
}{
|
||||
{ZoneStatusStandalone, "Standalone"},
|
||||
{ZoneStatusMaster, "Zone Master"},
|
||||
{ZoneStatusSlave, "Zone Member"},
|
||||
{ZoneStatus("UNKNOWN"), "Unknown"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := tt.status.String()
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_String(t *testing.T) {
|
||||
// Test standalone
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{},
|
||||
}
|
||||
|
||||
result := zi.String()
|
||||
expected := "Standalone device: MASTER123"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%s'", expected, result)
|
||||
}
|
||||
|
||||
// Test zone with members
|
||||
zi.Members = []Member{
|
||||
{DeviceID: "DEVICE456"},
|
||||
{DeviceID: "DEVICE789"},
|
||||
}
|
||||
|
||||
result = zi.String()
|
||||
expected = "Zone Master: MASTER123, Members: [DEVICE456, DEVICE789] (3 total devices)"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%s'", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneInfo_ToZoneRequest(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456", IP: "192.168.1.10"},
|
||||
{DeviceID: "DEVICE789", IP: "192.168.1.11"},
|
||||
},
|
||||
}
|
||||
|
||||
zr := zi.ToZoneRequest()
|
||||
|
||||
if zr.Master != zi.Master {
|
||||
t.Errorf("Expected master %s, got %s", zi.Master, zr.Master)
|
||||
}
|
||||
|
||||
if len(zr.Members) != len(zi.Members) {
|
||||
t.Errorf("Expected %d members, got %d", len(zi.Members), len(zr.Members))
|
||||
}
|
||||
|
||||
for i, member := range zi.Members {
|
||||
if zr.Members[i].DeviceID != member.DeviceID {
|
||||
t.Errorf("Expected member %s, got %s", member.DeviceID, zr.Members[i].DeviceID)
|
||||
}
|
||||
if zr.Members[i].IP != member.IP {
|
||||
t.Errorf("Expected IP %s, got %s", member.IP, zr.Members[i].IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneBuilder(t *testing.T) {
|
||||
zb := NewZoneBuilder("MASTER123")
|
||||
|
||||
zr, err := zb.
|
||||
WithMember("DEVICE456", "192.168.1.10").
|
||||
WithMemberByDeviceID("DEVICE789").
|
||||
Build()
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if zr.Master != "MASTER123" {
|
||||
t.Errorf("Expected master MASTER123, got %s", zr.Master)
|
||||
}
|
||||
|
||||
if len(zr.Members) != 2 {
|
||||
t.Errorf("Expected 2 members, got %d", len(zr.Members))
|
||||
}
|
||||
|
||||
if zr.Members[0].DeviceID != "DEVICE456" {
|
||||
t.Errorf("Expected first member DEVICE456, got %s", zr.Members[0].DeviceID)
|
||||
}
|
||||
|
||||
if zr.Members[0].IP != "192.168.1.10" {
|
||||
t.Errorf("Expected first member IP 192.168.1.10, got %s", zr.Members[0].IP)
|
||||
}
|
||||
|
||||
if zr.Members[1].DeviceID != "DEVICE789" {
|
||||
t.Errorf("Expected second member DEVICE789, got %s", zr.Members[1].DeviceID)
|
||||
}
|
||||
|
||||
if zr.Members[1].IP != "" {
|
||||
t.Errorf("Expected second member empty IP, got %s", zr.Members[1].IP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneBuilder_ValidationError(t *testing.T) {
|
||||
zb := NewZoneBuilder("")
|
||||
|
||||
_, err := zb.Build()
|
||||
if err == nil {
|
||||
t.Error("Expected validation error for empty master ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneOperation_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
op ZoneOperation
|
||||
expected string
|
||||
}{
|
||||
{ZoneOpCreate, "Create Zone"},
|
||||
{ZoneOpModify, "Modify Zone"},
|
||||
{ZoneOpAddMember, "Add Member"},
|
||||
{ZoneOpRemove, "Remove Member"},
|
||||
{ZoneOpDissolve, "Dissolve Zone"},
|
||||
{ZoneOperation("UNKNOWN"), "Unknown Operation"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := tt.op.String()
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
op ZoneOperation
|
||||
deviceID string
|
||||
reason string
|
||||
expectedMsg string
|
||||
}{
|
||||
{
|
||||
name: "Error with device ID",
|
||||
op: ZoneOpAddMember,
|
||||
deviceID: "DEVICE123",
|
||||
reason: "device offline",
|
||||
expectedMsg: "zone Add Member failed for device DEVICE123: device offline",
|
||||
},
|
||||
{
|
||||
name: "Error without device ID",
|
||||
op: ZoneOpCreate,
|
||||
deviceID: "",
|
||||
reason: "network error",
|
||||
expectedMsg: "zone Create Zone failed: network error",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := NewZoneError(tt.op, tt.deviceID, tt.reason)
|
||||
|
||||
if err.Error() != tt.expectedMsg {
|
||||
t.Errorf("Expected error message '%s', got '%s'", tt.expectedMsg, err.Error())
|
||||
}
|
||||
|
||||
if err.Operation != tt.op {
|
||||
t.Errorf("Expected operation %v, got %v", tt.op, err.Operation)
|
||||
}
|
||||
|
||||
if err.DeviceID != tt.deviceID {
|
||||
t.Errorf("Expected device ID '%s', got '%s'", tt.deviceID, err.DeviceID)
|
||||
}
|
||||
|
||||
if err.Reason != tt.reason {
|
||||
t.Errorf("Expected reason '%s', got '%s'", tt.reason, err.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneCapabilities(t *testing.T) {
|
||||
caps := DefaultZoneCapabilities()
|
||||
|
||||
if !caps.CanCreateZone() {
|
||||
t.Error("Expected default capabilities to allow zone creation")
|
||||
}
|
||||
|
||||
if !caps.CanJoinZone() {
|
||||
t.Error("Expected default capabilities to allow joining zones")
|
||||
}
|
||||
|
||||
// Test incapable device
|
||||
caps.SupportsMultiroom = false
|
||||
if caps.CanCreateZone() {
|
||||
t.Error("Expected device without multiroom support to not create zones")
|
||||
}
|
||||
|
||||
if caps.CanJoinZone() {
|
||||
t.Error("Expected device without multiroom support to not join zones")
|
||||
}
|
||||
|
||||
// Test master-only device
|
||||
caps.SupportsMultiroom = true
|
||||
caps.CanBeMember = false
|
||||
if caps.CanJoinZone() {
|
||||
t.Error("Expected device that can't be member to not join zones")
|
||||
}
|
||||
|
||||
if !caps.CanCreateZone() {
|
||||
t.Error("Expected device that can be master to create zones")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneXMLMarshaling(t *testing.T) {
|
||||
t.Run("ZoneInfo XML Unmarshaling", func(t *testing.T) {
|
||||
xmlData := `<zone master="MASTER123">
|
||||
<member ipaddress="192.168.1.10">DEVICE456</member>
|
||||
<member ipaddress="192.168.1.11">DEVICE789</member>
|
||||
</zone>`
|
||||
|
||||
var zi ZoneInfo
|
||||
err := xml.Unmarshal([]byte(xmlData), &zi)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal XML: %v", err)
|
||||
}
|
||||
|
||||
if zi.Master != "MASTER123" {
|
||||
t.Errorf("Expected master MASTER123, got %s", zi.Master)
|
||||
}
|
||||
|
||||
if len(zi.Members) != 2 {
|
||||
t.Errorf("Expected 2 members, got %d", len(zi.Members))
|
||||
}
|
||||
|
||||
if zi.Members[0].DeviceID != "DEVICE456" {
|
||||
t.Errorf("Expected first member DEVICE456, got %s", zi.Members[0].DeviceID)
|
||||
}
|
||||
|
||||
if zi.Members[0].IP != "192.168.1.10" {
|
||||
t.Errorf("Expected first member IP 192.168.1.10, got %s", zi.Members[0].IP)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ZoneRequest XML Marshaling", func(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMember("DEVICE456", "192.168.1.10")
|
||||
zr.AddMember("DEVICE789", "192.168.1.11")
|
||||
|
||||
data, err := xml.MarshalIndent(zr, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal XML: %v", err)
|
||||
}
|
||||
|
||||
expected := `<zone master="MASTER123">
|
||||
<member ipaddress="192.168.1.10">DEVICE456</member>
|
||||
<member ipaddress="192.168.1.11">DEVICE789</member>
|
||||
</zone>`
|
||||
|
||||
if string(data) != expected {
|
||||
t.Errorf("Expected XML:\n%s\n\nGot:\n%s", expected, string(data))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ZoneRequest XML Marshaling Without IP", func(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
zr.AddMemberByDeviceID("DEVICE456")
|
||||
|
||||
data, err := xml.MarshalIndent(zr, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal XML: %v", err)
|
||||
}
|
||||
|
||||
expected := `<zone master="MASTER123">
|
||||
<member>DEVICE456</member>
|
||||
</zone>`
|
||||
|
||||
if string(data) != expected {
|
||||
t.Errorf("Expected XML:\n%s\n\nGot:\n%s", expected, string(data))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Empty Zone XML", func(t *testing.T) {
|
||||
xmlData := `<zone master="MASTER123"></zone>`
|
||||
|
||||
var zi ZoneInfo
|
||||
err := xml.Unmarshal([]byte(xmlData), &zi)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal XML: %v", err)
|
||||
}
|
||||
|
||||
if zi.Master != "MASTER123" {
|
||||
t.Errorf("Expected master MASTER123, got %s", zi.Master)
|
||||
}
|
||||
|
||||
if len(zi.Members) != 0 {
|
||||
t.Errorf("Expected 0 members, got %d", len(zi.Members))
|
||||
}
|
||||
|
||||
if !zi.IsStandalone() {
|
||||
t.Error("Expected zone to be standalone")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestZoneEdgeCases(t *testing.T) {
|
||||
t.Run("Zone with many members", func(t *testing.T) {
|
||||
zr := NewZoneRequest("MASTER")
|
||||
for i := 1; i <= 10; i++ {
|
||||
deviceID := fmt.Sprintf("DEVICE%03d", i)
|
||||
ip := fmt.Sprintf("192.168.1.%d", i+10)
|
||||
zr.AddMember(deviceID, ip)
|
||||
}
|
||||
|
||||
if err := zr.Validate(); err != nil {
|
||||
t.Errorf("Expected valid zone request, got error: %v", err)
|
||||
}
|
||||
|
||||
if zr.GetMemberCount() != 10 {
|
||||
t.Errorf("Expected 10 members, got %d", zr.GetMemberCount())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Zone status for various scenarios", func(t *testing.T) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456"},
|
||||
},
|
||||
}
|
||||
|
||||
// Test all status types
|
||||
if zi.GetZoneStatus("MASTER123") != ZoneStatusMaster {
|
||||
t.Error("Master should have ZoneStatusMaster")
|
||||
}
|
||||
|
||||
if zi.GetZoneStatus("DEVICE456") != ZoneStatusSlave {
|
||||
t.Error("Member should have ZoneStatusSlave")
|
||||
}
|
||||
|
||||
if zi.GetZoneStatus("OTHER") != ZoneStatusStandalone {
|
||||
t.Error("Unknown device should have ZoneStatusStandalone")
|
||||
}
|
||||
|
||||
// Test standalone scenario
|
||||
zi.Members = []Member{}
|
||||
if zi.GetZoneStatus("MASTER123") != ZoneStatusStandalone {
|
||||
t.Error("Standalone master should have ZoneStatusStandalone")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkZoneInfo_GetAllDeviceIDs(b *testing.B) {
|
||||
zi := &ZoneInfo{
|
||||
Master: "MASTER123",
|
||||
Members: []Member{
|
||||
{DeviceID: "DEVICE456"},
|
||||
{DeviceID: "DEVICE789"},
|
||||
{DeviceID: "DEVICEABC"},
|
||||
{DeviceID: "DEVICEDEF"},
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = zi.GetAllDeviceIDs()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkZoneRequest_Validate(b *testing.B) {
|
||||
zr := NewZoneRequest("MASTER123")
|
||||
for i := 0; i < 5; i++ {
|
||||
deviceID := fmt.Sprintf("DEVICE%d", i)
|
||||
ip := fmt.Sprintf("192.168.1.%d", i+10)
|
||||
zr.AddMember(deviceID, ip)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = zr.Validate()
|
||||
}
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
Reference in New Issue
Block a user