mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
- Removed unused result parameter from internal post() method - Updated all 18 callers to remove nil result parameter - Deleted unused XML unmarshaling logic for POST responses - Simplified method signature from post(endpoint, payload, result) to post(endpoint, payload) Since all callers passed nil for result parameter, this simplifies the API without breaking any functionality. POST operations in this API don't return data that needs unmarshaling. Progress: Resolved final unparam issue Total issues: 16 → 15 (6% improvement) Remaining: - gocyclo: 14 (high function complexity) - revive: 1 (DiscoveryService naming)
919 lines
24 KiB
Go
919 lines
24 KiB
Go
// Package client provides HTTP client functionality for interacting with Bose SoundTouch devices.
|
|
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/user_account/bose-soundtouch/pkg/models"
|
|
)
|
|
|
|
// defaultSoundTouchPort is the standard port for SoundTouch devices
|
|
const defaultSoundTouchPort = 8090
|
|
|
|
// Client represents a SoundTouch API client
|
|
type Client struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
timeout time.Duration
|
|
userAgent string
|
|
}
|
|
|
|
// Config holds configuration for the SoundTouch client
|
|
type Config struct {
|
|
Host string
|
|
Port int
|
|
Timeout time.Duration
|
|
UserAgent string
|
|
}
|
|
|
|
// DefaultConfig returns a default client configuration
|
|
func DefaultConfig() *Config {
|
|
return &Config{
|
|
Host: "localhost",
|
|
Port: 8090,
|
|
Timeout: 30 * time.Second,
|
|
UserAgent: "Bose-SoundTouch-Go-Client/1.0",
|
|
}
|
|
}
|
|
|
|
// NewClient creates a new SoundTouch API client
|
|
func NewClient(config *Config) *Client {
|
|
if config.Timeout == 0 {
|
|
config.Timeout = 10 * time.Second
|
|
}
|
|
|
|
if config.UserAgent == "" {
|
|
config.UserAgent = "Bose-SoundTouch-Go-Client/1.0"
|
|
}
|
|
|
|
if config.Port == 0 {
|
|
config.Port = 8090
|
|
}
|
|
|
|
return &Client{
|
|
baseURL: fmt.Sprintf("http://%s:%d", config.Host, config.Port),
|
|
httpClient: &http.Client{
|
|
Timeout: config.Timeout,
|
|
},
|
|
timeout: config.Timeout,
|
|
userAgent: config.UserAgent,
|
|
}
|
|
}
|
|
|
|
// NewClientFromHost creates a new client with just a host address
|
|
func NewClientFromHost(host string) *Client {
|
|
config := DefaultConfig()
|
|
config.Host = host
|
|
|
|
return NewClient(config)
|
|
}
|
|
|
|
// GetDeviceInfo retrieves device information from the /info endpoint
|
|
func (c *Client) GetDeviceInfo() (*models.DeviceInfo, error) {
|
|
var deviceInfo models.DeviceInfo
|
|
|
|
err := c.get("/info", &deviceInfo)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get device info: %w", err)
|
|
}
|
|
|
|
return &deviceInfo, nil
|
|
}
|
|
|
|
// GetNowPlaying retrieves current playback information from the /now_playing endpoint
|
|
func (c *Client) GetNowPlaying() (*models.NowPlaying, error) {
|
|
var nowPlaying models.NowPlaying
|
|
|
|
err := c.get("/now_playing", &nowPlaying)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get now playing: %w", err)
|
|
}
|
|
|
|
return &nowPlaying, nil
|
|
}
|
|
|
|
// GetSources retrieves available audio sources from the /sources endpoint
|
|
func (c *Client) GetSources() (*models.Sources, error) {
|
|
var sources models.Sources
|
|
|
|
err := c.get("/sources", &sources)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get sources: %w", err)
|
|
}
|
|
|
|
return &sources, nil
|
|
}
|
|
|
|
// GetName retrieves the device name from the /name endpoint
|
|
func (c *Client) GetName() (*models.Name, error) {
|
|
var name models.Name
|
|
|
|
err := c.get("/name", &name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get device name: %w", err)
|
|
}
|
|
|
|
return &name, nil
|
|
}
|
|
|
|
// GetCapabilities retrieves device capabilities from the /capabilities endpoint
|
|
func (c *Client) GetCapabilities() (*models.Capabilities, error) {
|
|
var capabilities models.Capabilities
|
|
|
|
err := c.get("/capabilities", &capabilities)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get device capabilities: %w", err)
|
|
}
|
|
|
|
return &capabilities, nil
|
|
}
|
|
|
|
// GetPresets retrieves configured presets from the /presets endpoint
|
|
func (c *Client) GetPresets() (*models.Presets, error) {
|
|
var presets models.Presets
|
|
|
|
err := c.get("/presets", &presets)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get presets: %w", err)
|
|
}
|
|
|
|
return &presets, nil
|
|
}
|
|
|
|
// GetNextAvailablePresetSlot returns the next available preset slot (1-6), or error if all are used
|
|
func (c *Client) GetNextAvailablePresetSlot() (int, error) {
|
|
presets, err := c.GetPresets()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to get presets: %w", err)
|
|
}
|
|
|
|
emptySlots := presets.GetEmptyPresetSlots()
|
|
if len(emptySlots) == 0 {
|
|
return 0, fmt.Errorf("all preset slots are occupied")
|
|
}
|
|
|
|
// Return the first available slot
|
|
return emptySlots[0], nil
|
|
}
|
|
|
|
// IsCurrentContentPresetable checks if the currently playing content can be saved as a preset
|
|
func (c *Client) IsCurrentContentPresetable() (bool, error) {
|
|
nowPlaying, err := c.GetNowPlaying()
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to get now playing: %w", err)
|
|
}
|
|
|
|
if nowPlaying.IsEmpty() || nowPlaying.ContentItem == nil {
|
|
return false, nil
|
|
}
|
|
|
|
return nowPlaying.ContentItem.IsPresetable, nil
|
|
}
|
|
|
|
// SendKey sends a key press command to the device (press followed by release)
|
|
func (c *Client) SendKey(keyValue string) error {
|
|
if !models.IsValidKey(keyValue) {
|
|
return fmt.Errorf("invalid key value: %s", keyValue)
|
|
}
|
|
|
|
// Send press state
|
|
keyPress := models.NewKey(keyValue)
|
|
|
|
err := c.post("/key", keyPress)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send key press: %w", err)
|
|
}
|
|
|
|
// Send release state
|
|
keyRelease := models.NewKeyRelease(keyValue)
|
|
|
|
err = c.post("/key", keyRelease)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send key release: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SendKeyPress sends a key press command (alias for SendKey - sends press+release)
|
|
func (c *Client) SendKeyPress(keyValue string) error {
|
|
return c.SendKey(keyValue)
|
|
}
|
|
|
|
// SendKeyPressOnly sends only the key press state (without release)
|
|
func (c *Client) SendKeyPressOnly(keyValue string) error {
|
|
if !models.IsValidKey(keyValue) {
|
|
return fmt.Errorf("invalid key value: %s", keyValue)
|
|
}
|
|
|
|
key := models.NewKey(keyValue)
|
|
|
|
return c.post("/key", key)
|
|
}
|
|
|
|
// SendKeyRelease sends a key release command
|
|
func (c *Client) SendKeyRelease(keyValue string) error {
|
|
if !models.IsValidKey(keyValue) {
|
|
return fmt.Errorf("invalid key value: %s", keyValue)
|
|
}
|
|
|
|
key := models.NewKeyRelease(keyValue)
|
|
|
|
return c.post("/key", key)
|
|
}
|
|
|
|
// SendKeyReleaseOnly sends only the key release state (alias for SendKeyRelease)
|
|
func (c *Client) SendKeyReleaseOnly(keyValue string) error {
|
|
return c.SendKeyRelease(keyValue)
|
|
}
|
|
|
|
// Play sends a PLAY key command
|
|
func (c *Client) Play() error {
|
|
return c.SendKey(models.KeyPlay)
|
|
}
|
|
|
|
// Pause sends a PAUSE key command
|
|
func (c *Client) Pause() error {
|
|
return c.SendKey(models.KeyPause)
|
|
}
|
|
|
|
// Stop sends a STOP key command
|
|
func (c *Client) Stop() error {
|
|
return c.SendKey(models.KeyStop)
|
|
}
|
|
|
|
// NextTrack sends a NEXT_TRACK key command
|
|
func (c *Client) NextTrack() error {
|
|
return c.SendKey(models.KeyNextTrack)
|
|
}
|
|
|
|
// PrevTrack sends a PREV_TRACK key command
|
|
func (c *Client) PrevTrack() error {
|
|
return c.SendKey(models.KeyPrevTrack)
|
|
}
|
|
|
|
// VolumeUp sends a VOLUME_UP key command
|
|
func (c *Client) VolumeUp() error {
|
|
return c.SendKey(models.KeyVolumeUp)
|
|
}
|
|
|
|
// VolumeDown sends a VOLUME_DOWN key command
|
|
func (c *Client) VolumeDown() error {
|
|
return c.SendKey(models.KeyVolumeDown)
|
|
}
|
|
|
|
// SelectPreset sends a preset key command (1-6)
|
|
func (c *Client) SelectPreset(presetNumber int) error {
|
|
var keyValue string
|
|
|
|
switch presetNumber {
|
|
case 1:
|
|
keyValue = models.KeyPreset1
|
|
case 2:
|
|
keyValue = models.KeyPreset2
|
|
case 3:
|
|
keyValue = models.KeyPreset3
|
|
case 4:
|
|
keyValue = models.KeyPreset4
|
|
case 5:
|
|
keyValue = models.KeyPreset5
|
|
case 6:
|
|
keyValue = models.KeyPreset6
|
|
default:
|
|
return fmt.Errorf("invalid preset number: %d (must be 1-6)", presetNumber)
|
|
}
|
|
|
|
return c.SendKey(keyValue)
|
|
}
|
|
|
|
// GetVolume retrieves the current volume level from the /volume endpoint
|
|
func (c *Client) GetVolume() (*models.Volume, error) {
|
|
var volume models.Volume
|
|
|
|
err := c.get("/volume", &volume)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get volume: %w", err)
|
|
}
|
|
|
|
return &volume, nil
|
|
}
|
|
|
|
// SetVolume sets the volume level using the /volume endpoint
|
|
func (c *Client) SetVolume(level int) error {
|
|
if !models.ValidateVolumeLevel(level) {
|
|
return fmt.Errorf("invalid volume level: %d (must be 0-100)", level)
|
|
}
|
|
|
|
volumeReq := models.NewVolumeRequest(level)
|
|
|
|
return c.post("/volume", volumeReq)
|
|
}
|
|
|
|
// SetVolumeSafe sets volume with validation and clamping
|
|
func (c *Client) SetVolumeSafe(level int) error {
|
|
clampedLevel := models.ClampVolumeLevel(level)
|
|
return c.SetVolume(clampedLevel)
|
|
}
|
|
|
|
// IncreaseVolume increases volume by the specified amount (with safety limits)
|
|
func (c *Client) IncreaseVolume(amount int) (*models.Volume, error) {
|
|
currentVolume, err := c.GetVolume()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get current volume: %w", err)
|
|
}
|
|
|
|
newLevel := models.ClampVolumeLevel(currentVolume.GetLevel() + amount)
|
|
|
|
err = c.SetVolume(newLevel)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to set volume: %w", err)
|
|
}
|
|
|
|
// Return updated volume
|
|
return c.GetVolume()
|
|
}
|
|
|
|
// DecreaseVolume decreases volume by the specified amount (with safety limits)
|
|
func (c *Client) DecreaseVolume(amount int) (*models.Volume, error) {
|
|
currentVolume, err := c.GetVolume()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get current volume: %w", err)
|
|
}
|
|
|
|
newLevel := models.ClampVolumeLevel(currentVolume.GetLevel() - amount)
|
|
|
|
err = c.SetVolume(newLevel)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to set volume: %w", err)
|
|
}
|
|
|
|
// Return updated volume
|
|
return c.GetVolume()
|
|
}
|
|
|
|
// GetBass retrieves the current bass level from the /bass endpoint
|
|
func (c *Client) GetBass() (*models.Bass, error) {
|
|
var bass models.Bass
|
|
|
|
err := c.get("/bass", &bass)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get bass: %w", err)
|
|
}
|
|
|
|
return &bass, nil
|
|
}
|
|
|
|
// SetBass sets the bass level using the /bass endpoint
|
|
func (c *Client) SetBass(level int) error {
|
|
if !models.ValidateBassLevel(level) {
|
|
return fmt.Errorf("invalid bass level: %d (must be between %d and %d)", level, models.BassLevelMin, models.BassLevelMax)
|
|
}
|
|
|
|
bassReq, err := models.NewBassRequest(level)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create bass request: %w", err)
|
|
}
|
|
|
|
return c.post("/bass", bassReq)
|
|
}
|
|
|
|
// SetBassSafe sets bass with validation and clamping
|
|
func (c *Client) SetBassSafe(level int) error {
|
|
clampedLevel := models.ClampBassLevel(level)
|
|
return c.SetBass(clampedLevel)
|
|
}
|
|
|
|
// IncreaseBass increases bass by the specified amount (with safety limits)
|
|
func (c *Client) IncreaseBass(amount int) (*models.Bass, error) {
|
|
currentBass, err := c.GetBass()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get current bass: %w", err)
|
|
}
|
|
|
|
newLevel := models.ClampBassLevel(currentBass.GetLevel() + amount)
|
|
|
|
err = c.SetBass(newLevel)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to set bass: %w", err)
|
|
}
|
|
|
|
// Return updated bass
|
|
return c.GetBass()
|
|
}
|
|
|
|
// DecreaseBass decreases bass by the specified amount (with safety limits)
|
|
func (c *Client) DecreaseBass(amount int) (*models.Bass, error) {
|
|
currentBass, err := c.GetBass()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get current bass: %w", err)
|
|
}
|
|
|
|
newLevel := models.ClampBassLevel(currentBass.GetLevel() - amount)
|
|
|
|
err = c.SetBass(newLevel)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to set bass: %w", err)
|
|
}
|
|
|
|
// Return updated bass
|
|
return c.GetBass()
|
|
}
|
|
|
|
// GetBalance retrieves the current balance level from the /balance endpoint
|
|
func (c *Client) GetBalance() (*models.Balance, error) {
|
|
var balance models.Balance
|
|
|
|
err := c.get("/balance", &balance)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get balance: %w", err)
|
|
}
|
|
|
|
return &balance, nil
|
|
}
|
|
|
|
// SetBalance sets the balance level using the /balance endpoint
|
|
func (c *Client) SetBalance(level int) error {
|
|
if !models.ValidateBalanceLevel(level) {
|
|
return fmt.Errorf("invalid balance level: %d (must be between %d and %d)", level, models.BalanceLevelMin, models.BalanceLevelMax)
|
|
}
|
|
|
|
balanceReq, err := models.NewBalanceRequest(level)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create balance request: %w", err)
|
|
}
|
|
|
|
return c.post("/balance", balanceReq)
|
|
}
|
|
|
|
// SetBalanceSafe sets balance with validation and clamping
|
|
func (c *Client) SetBalanceSafe(level int) error {
|
|
clampedLevel := models.ClampBalanceLevel(level)
|
|
return c.SetBalance(clampedLevel)
|
|
}
|
|
|
|
// IncreaseBalance increases balance by the specified amount (with safety limits)
|
|
func (c *Client) IncreaseBalance(amount int) (*models.Balance, error) {
|
|
currentBalance, err := c.GetBalance()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get current balance: %w", err)
|
|
}
|
|
|
|
newLevel := models.ClampBalanceLevel(currentBalance.GetLevel() + amount)
|
|
|
|
err = c.SetBalance(newLevel)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to set balance: %w", err)
|
|
}
|
|
|
|
// Return updated balance
|
|
return c.GetBalance()
|
|
}
|
|
|
|
// DecreaseBalance decreases balance by the specified amount (with safety limits)
|
|
func (c *Client) DecreaseBalance(amount int) (*models.Balance, error) {
|
|
currentBalance, err := c.GetBalance()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get current balance: %w", err)
|
|
}
|
|
|
|
newLevel := models.ClampBalanceLevel(currentBalance.GetLevel() - amount)
|
|
|
|
err = c.SetBalance(newLevel)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to set balance: %w", err)
|
|
}
|
|
|
|
// Return updated balance
|
|
return c.GetBalance()
|
|
}
|
|
|
|
// SelectSource selects an audio source using the /select endpoint
|
|
func (c *Client) SelectSource(source, sourceAccount string) error {
|
|
// Validate source parameter
|
|
if source == "" {
|
|
return fmt.Errorf("source cannot be empty")
|
|
}
|
|
|
|
// Create ContentItem for source selection
|
|
contentItem := &models.ContentItem{
|
|
Source: source,
|
|
SourceAccount: sourceAccount,
|
|
ItemName: source, // Use source as default item name
|
|
}
|
|
|
|
// For certain sources, we might want to customize the item name
|
|
switch source {
|
|
case "SPOTIFY":
|
|
contentItem.ItemName = "Spotify"
|
|
case "BLUETOOTH":
|
|
contentItem.ItemName = "Bluetooth"
|
|
case "AUX":
|
|
contentItem.ItemName = "AUX Input"
|
|
case "TUNEIN":
|
|
contentItem.ItemName = "TuneIn"
|
|
case "PANDORA":
|
|
contentItem.ItemName = "Pandora"
|
|
case "AMAZON":
|
|
contentItem.ItemName = "Amazon Music"
|
|
case "IHEARTRADIO":
|
|
contentItem.ItemName = "iHeartRadio"
|
|
case "STORED_MUSIC":
|
|
contentItem.ItemName = "Stored Music"
|
|
}
|
|
|
|
return c.post("/select", contentItem)
|
|
}
|
|
|
|
// SelectSourceFromItem selects an audio source using a SourceItem
|
|
func (c *Client) SelectSourceFromItem(sourceItem *models.SourceItem) error {
|
|
if sourceItem == nil {
|
|
return fmt.Errorf("sourceItem cannot be nil")
|
|
}
|
|
|
|
return c.SelectSource(sourceItem.Source, sourceItem.SourceAccount)
|
|
}
|
|
|
|
// SelectSpotify is a convenience method to select Spotify source
|
|
func (c *Client) SelectSpotify(sourceAccount string) error {
|
|
return c.SelectSource("SPOTIFY", sourceAccount)
|
|
}
|
|
|
|
// SelectBluetooth is a convenience method to select Bluetooth source
|
|
func (c *Client) SelectBluetooth() error {
|
|
return c.SelectSource("BLUETOOTH", "")
|
|
}
|
|
|
|
// SelectAux is a convenience method to select AUX input
|
|
func (c *Client) SelectAux() error {
|
|
return c.SelectSource("AUX", "")
|
|
}
|
|
|
|
// SelectTuneIn is a convenience method to select TuneIn source
|
|
func (c *Client) SelectTuneIn(sourceAccount string) error {
|
|
return c.SelectSource("TUNEIN", sourceAccount)
|
|
}
|
|
|
|
// SelectPandora is a convenience method to select Pandora source
|
|
func (c *Client) SelectPandora(sourceAccount string) error {
|
|
return c.SelectSource("PANDORA", sourceAccount)
|
|
}
|
|
|
|
// GetClockTime retrieves the device's current time from the /clockTime endpoint
|
|
func (c *Client) GetClockTime() (*models.ClockTime, error) {
|
|
var clockTime models.ClockTime
|
|
|
|
err := c.get("/clockTime", &clockTime)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get clock time: %w", err)
|
|
}
|
|
|
|
return &clockTime, nil
|
|
}
|
|
|
|
// SetClockTime sets the device's time via the /clockTime endpoint
|
|
func (c *Client) SetClockTime(request *models.ClockTimeRequest) error {
|
|
if err := request.Validate(); err != nil {
|
|
return fmt.Errorf("invalid clock time request: %w", err)
|
|
}
|
|
|
|
err := c.post("/clockTime", request)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set clock time: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetClockTimeNow sets the device's time to the current system time
|
|
func (c *Client) SetClockTimeNow() error {
|
|
request := models.NewClockTimeRequest(time.Now())
|
|
return c.SetClockTime(request)
|
|
}
|
|
|
|
// GetClockDisplay retrieves clock display settings from the /clockDisplay endpoint
|
|
func (c *Client) GetClockDisplay() (*models.ClockDisplay, error) {
|
|
var clockDisplay models.ClockDisplay
|
|
|
|
err := c.get("/clockDisplay", &clockDisplay)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get clock display settings: %w", err)
|
|
}
|
|
|
|
return &clockDisplay, nil
|
|
}
|
|
|
|
// SetClockDisplay configures clock display settings via the /clockDisplay endpoint
|
|
func (c *Client) SetClockDisplay(request *models.ClockDisplayRequest) error {
|
|
if err := request.Validate(); err != nil {
|
|
return fmt.Errorf("invalid clock display request: %w", err)
|
|
}
|
|
|
|
if !request.HasChanges() {
|
|
return fmt.Errorf("no changes specified in clock display request")
|
|
}
|
|
|
|
err := c.post("/clockDisplay", request)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set clock display: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// EnableClockDisplay enables the clock display with default settings
|
|
func (c *Client) EnableClockDisplay() error {
|
|
request := models.NewClockDisplayRequest().SetEnabled(true)
|
|
return c.SetClockDisplay(request)
|
|
}
|
|
|
|
// DisableClockDisplay disables the clock display
|
|
func (c *Client) DisableClockDisplay() error {
|
|
request := models.NewClockDisplayRequest().SetEnabled(false)
|
|
return c.SetClockDisplay(request)
|
|
}
|
|
|
|
// SetClockDisplayBrightness sets the clock display brightness (0-100)
|
|
func (c *Client) SetClockDisplayBrightness(brightness int) error {
|
|
request := models.NewClockDisplayRequest().SetBrightness(brightness)
|
|
return c.SetClockDisplay(request)
|
|
}
|
|
|
|
// SetClockDisplayFormat sets the clock display format (12/24 hour)
|
|
func (c *Client) SetClockDisplayFormat(format models.ClockFormat) error {
|
|
request := models.NewClockDisplayRequest().SetFormat(format)
|
|
return c.SetClockDisplay(request)
|
|
}
|
|
|
|
// GetNetworkInfo retrieves network information from the /networkInfo endpoint
|
|
func (c *Client) GetNetworkInfo() (*models.NetworkInformation, error) {
|
|
var networkInfo models.NetworkInformation
|
|
|
|
err := c.get("/networkInfo", &networkInfo)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get network info: %w", err)
|
|
}
|
|
|
|
return &networkInfo, nil
|
|
}
|
|
|
|
// Ping checks if the device is reachable by calling /info
|
|
func (c *Client) Ping() error {
|
|
_, err := c.GetDeviceInfo()
|
|
return err
|
|
}
|
|
|
|
// BaseURL returns the base URL for this client
|
|
func (c *Client) BaseURL() string {
|
|
return c.baseURL
|
|
}
|
|
|
|
// Host returns the host for this client
|
|
func (c *Client) Host() string {
|
|
return c.baseURL
|
|
}
|
|
|
|
// get performs a GET request and unmarshals the XML response
|
|
func (c *Client) get(endpoint string, result interface{}) error {
|
|
url := c.baseURL + endpoint
|
|
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("User-Agent", c.userAgent)
|
|
req.Header.Set("Accept", "application/xml")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to execute request: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
if closeErr := resp.Body.Close(); closeErr != nil {
|
|
// Log the error but don't override the main error
|
|
_ = closeErr // Explicitly ignore the error
|
|
}
|
|
}()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
// Parse the actual response first
|
|
if err := xml.Unmarshal(body, result); err != nil {
|
|
// Check if it might be an API error response instead
|
|
var apiError models.APIError
|
|
if xmlErr := xml.Unmarshal(body, &apiError); xmlErr == nil && apiError.Message != "" {
|
|
return &apiError
|
|
}
|
|
|
|
return fmt.Errorf("failed to unmarshal XML response: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// post performs a POST request with XML body
|
|
func (c *Client) post(endpoint string, payload interface{}) error {
|
|
url := c.baseURL + endpoint
|
|
|
|
var body io.Reader
|
|
|
|
if payload != nil {
|
|
xmlData, err := xml.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal XML request: %w", err)
|
|
}
|
|
|
|
body = bytes.NewReader(xmlData)
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", url, body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("User-Agent", c.userAgent)
|
|
req.Header.Set("Content-Type", "application/xml")
|
|
req.Header.Set("Accept", "application/xml")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to execute request: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
if closeErr := resp.Body.Close(); closeErr != nil {
|
|
// Log the error but don't override the main error
|
|
_ = closeErr // Explicitly ignore the error
|
|
}
|
|
}()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
responseBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(responseBody))
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// SetName sets the device name
|
|
func (c *Client) SetName(name string) error {
|
|
nameRequest := models.Name{
|
|
XMLName: xml.Name{Local: "name"},
|
|
Value: name,
|
|
}
|
|
|
|
return c.post("/name", nameRequest)
|
|
}
|
|
|
|
// GetBassCapabilities retrieves the bass capabilities for the device
|
|
func (c *Client) GetBassCapabilities() (*models.BassCapabilities, error) {
|
|
var bassCapabilities models.BassCapabilities
|
|
|
|
err := c.get("/bassCapabilities", &bassCapabilities)
|
|
|
|
return &bassCapabilities, err
|
|
}
|
|
|
|
// GetTrackInfo retrieves track information (duplicate of GetNowPlaying per official API)
|
|
func (c *Client) GetTrackInfo() (*models.NowPlaying, error) {
|
|
var nowPlaying models.NowPlaying
|
|
|
|
err := c.get("/trackInfo", &nowPlaying)
|
|
|
|
return &nowPlaying, err
|
|
}
|