diff --git a/SPEAKER_ENDPOINT.md b/SPEAKER_ENDPOINT.md
new file mode 100644
index 0000000..37de257
--- /dev/null
+++ b/SPEAKER_ENDPOINT.md
@@ -0,0 +1,311 @@
+# SoundTouch Speaker Endpoint Documentation
+
+This document describes the implementation of the `/speaker` endpoint for Bose SoundTouch devices, which enables Text-To-Speech (TTS) notifications and URL content playback.
+
+## Overview
+
+The `/speaker` endpoint is used to play notification content on SoundTouch devices, including:
+- Text-To-Speech messages using Google TTS
+- Audio content from HTTP/HTTPS URLs
+- Notification beeps (via `/playNotification` endpoint)
+
+**Important**: This functionality is primarily supported by ST-10 (Series III) speakers. ST-300 and other models may not support this endpoint despite it appearing in their supported URLs.
+
+## API Reference
+
+### POST /speaker
+
+Plays notification content on the speaker.
+
+**Request Body:**
+```xml
+
+ URL_TO_AUDIO_CONTENT
+ YOUR_APPLICATION_KEY
+ SERVICE_NAME
+ MESSAGE_DESCRIPTION
+ REASON_OR_FILENAME
+ VOLUME_LEVEL
+
+```
+
+**Response:**
+```xml
+
+/speaker
+```
+
+### GET /playNotification
+
+Plays a simple notification beep sound.
+
+**Response:**
+```xml
+
+/playNotification
+```
+
+## Go Client Library Usage
+
+### Text-To-Speech (TTS)
+
+```go
+package main
+
+import (
+ "log"
+ "github.com/gesellix/bose-soundtouch/pkg/client"
+ "github.com/gesellix/bose-soundtouch/pkg/models"
+)
+
+func main() {
+ config := &client.Config{
+ Host: "192.168.1.100",
+ Port: 8090,
+ }
+
+ client := client.NewClient(config)
+
+ // Play TTS at current volume
+ err := client.PlayTTS("Hello, this is a test message", "YOUR_APP_KEY")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // Play TTS at specific volume (70)
+ err = client.PlayTTS("Volume test message", "YOUR_APP_KEY", 70)
+ if err != nil {
+ log.Fatal(err)
+ }
+}
+```
+
+### URL Content Playback
+
+```go
+func main() {
+ config := &client.Config{
+ Host: "192.168.1.100",
+ Port: 8090,
+ }
+
+ client := client.NewClient(config)
+
+ // Play audio from URL
+ err := client.PlayURL(
+ "https://example.com/audio.mp3",
+ "YOUR_APP_KEY",
+ "Music Service",
+ "Song Title",
+ "Artist Name",
+ 50, // volume level
+ )
+ if err != nil {
+ log.Fatal(err)
+ }
+}
+```
+
+### Custom PlayInfo
+
+```go
+func main() {
+ client := client.NewClient(config)
+
+ // Create custom play info
+ playInfo := models.NewPlayInfo(
+ "https://example.com/audio.mp3",
+ "YOUR_APP_KEY",
+ "Custom Service",
+ "Custom Message",
+ "Custom Reason",
+ ).SetVolume(60)
+
+ err := client.PlayCustom(playInfo)
+ if err != nil {
+ log.Fatal(err)
+ }
+}
+```
+
+### Notification Beep
+
+```go
+func main() {
+ client := client.NewClient(config)
+
+ err := client.PlayNotificationBeep()
+ if err != nil {
+ log.Fatal(err)
+ }
+}
+```
+
+## CLI Usage
+
+### Text-To-Speech
+
+```bash
+# Basic TTS (English)
+soundtouch-cli speaker tts --text "Hello World" --app-key YOUR_KEY --host 192.168.1.100
+
+# TTS with volume and language
+soundtouch-cli speaker tts \
+ --text "Bonjour le monde" \
+ --app-key YOUR_KEY \
+ --volume 70 \
+ --language FR \
+ --host 192.168.1.100
+```
+
+### URL Content Playback
+
+```bash
+# Basic URL playback
+soundtouch-cli speaker url \
+ --url "https://example.com/audio.mp3" \
+ --app-key YOUR_KEY \
+ --host 192.168.1.100
+
+# URL playback with custom metadata
+soundtouch-cli speaker url \
+ --url "https://example.com/song.mp3" \
+ --app-key YOUR_KEY \
+ --service "My Music Service" \
+ --message "Beautiful Song" \
+ --reason "Artist Name" \
+ --volume 60 \
+ --host 192.168.1.100
+```
+
+### Notification Beep
+
+```bash
+soundtouch-cli speaker beep --host 192.168.1.100
+```
+
+### Help
+
+```bash
+# General speaker help
+soundtouch-cli speaker --help
+
+# Detailed functionality help
+soundtouch-cli speaker help
+
+# Command-specific help
+soundtouch-cli speaker tts --help
+soundtouch-cli speaker url --help
+```
+
+## Supported Languages for TTS
+
+The following language codes are supported for Google TTS:
+
+| Code | Language |
+|------|----------|
+| EN | English |
+| DE | German |
+| ES | Spanish |
+| FR | French |
+| IT | Italian |
+| NL | Dutch |
+| PT | Portuguese |
+| RU | Russian |
+| ZH | Chinese |
+| JA | Japanese |
+| KO | Korean |
+| AR | Arabic |
+| HI | Hindi |
+| TH | Thai |
+
+## Behavior Notes
+
+1. **Volume Control**: If a volume is specified, the device will:
+ - Switch to the specified volume for playback
+ - Automatically restore the previous volume after playback completes
+ - If volume is 0 or omitted, content plays at current volume
+
+2. **Content Interruption**:
+ - Currently playing content is paused during notification playback
+ - Original content resumes automatically after notification ends
+ - If currently playing content is already a notification, you may get an error
+
+3. **Multiroom Behavior**:
+ - If the device is a zone master, notifications play on all zone members
+ - Volume changes affect all devices in the zone
+
+4. **Now Playing Display**:
+ - Service name appears in the "artist" field
+ - Message appears in the "album" field
+ - Reason appears in the "track" field
+ - Custom artwork can be included in URL-based content
+
+## Error Handling
+
+Common errors and their meanings:
+
+- **Device not found**: Check host/port configuration
+- **Endpoint not supported**: Device doesn't support `/speaker` endpoint (common with ST-300)
+- **Invalid app key**: App key is required for TTS and URL playback
+- **Network timeout**: Check device connectivity
+- **Invalid URL**: URL must be accessible and contain valid audio content
+
+## App Key Requirements
+
+Both TTS and URL playback require an `app_key` parameter. This appears to be used for:
+- Request authentication/identification
+- Rate limiting
+- Service tracking
+
+You'll need to provide your own application key. The format and generation method for valid app keys is not documented in the official API.
+
+## Limitations
+
+1. **Device Support**: Limited to specific SoundTouch models (primarily ST-10 Series III)
+2. **Audio Formats**: Supported audio formats depend on device capabilities
+3. **URL Requirements**: URLs must be publicly accessible (no authentication)
+4. **TTS Length**: Very long TTS messages may be truncated
+5. **Concurrent Playback**: Cannot play multiple notifications simultaneously
+
+## Integration Examples
+
+### Home Automation
+
+```go
+// Doorbell notification
+client.PlayTTS("Someone is at the front door", "home-automation-key", 80)
+
+// Security alert
+client.PlayURL(
+ "https://myserver.com/alerts/security-breach.mp3",
+ "security-system-key",
+ "Security System",
+ "Alert",
+ "Motion detected in restricted area",
+ 100,
+)
+```
+
+### Development/Testing
+
+```bash
+# Test connectivity
+soundtouch-cli speaker beep --host 192.168.1.100
+
+# Test TTS functionality
+soundtouch-cli speaker tts --text "Testing TTS functionality" --app-key test-key --host 192.168.1.100
+
+# Test URL playback
+soundtouch-cli speaker url --url "https://www.soundjay.com/misc/sounds/bell-ringing-05.wav" --app-key test-key --host 192.168.1.100
+```
+
+## Troubleshooting
+
+1. **Command not found**: Ensure you're using a supported SoundTouch model
+2. **No audio output**: Check volume levels and device status
+3. **TTS not working**: Verify internet connectivity for Google TTS service
+4. **URL content fails**: Ensure URL is accessible and contains valid audio
+5. **Volume not restored**: May occur if device is powered off during playback
+
+For more information, see the [SoundTouch WebServices API documentation](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API).
\ No newline at end of file
diff --git a/cmd/soundtouch-cli/cmd_speaker.go b/cmd/soundtouch-cli/cmd_speaker.go
new file mode 100644
index 0000000..5bdf9d0
--- /dev/null
+++ b/cmd/soundtouch-cli/cmd_speaker.go
@@ -0,0 +1,205 @@
+package main
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+
+ "github.com/gesellix/bose-soundtouch/pkg/models"
+ "github.com/urfave/cli/v2"
+)
+
+// playTTS plays a Text-To-Speech message on the speaker
+func playTTS(c *cli.Context) error {
+ clientConfig := GetClientConfig(c)
+ text := c.String("text")
+ appKey := c.String("app-key")
+ volume := c.Int("volume")
+ language := c.String("language")
+
+ if text == "" {
+ PrintError("Text message is required")
+ return fmt.Errorf("text message cannot be empty")
+ }
+
+ if appKey == "" {
+ PrintError("App key is required")
+ return fmt.Errorf("app key cannot be empty")
+ }
+
+ PrintDeviceHeader(fmt.Sprintf("Playing TTS message: \"%s\"", text), clientConfig.Host, clientConfig.Port)
+
+ client, err := CreateSoundTouchClient(clientConfig)
+ if err != nil {
+ PrintError(fmt.Sprintf("Failed to create client: %v", err))
+ return err
+ }
+
+ // URL encode the text for Google TTS
+ encodedText := url.QueryEscape(text)
+
+ // Build TTS URL with language support
+ ttsURL := fmt.Sprintf("http://translate.google.com/translate_tts?ie=UTF-8&tl=%s&client=tw-ob&q=%s", language, encodedText)
+
+ // Create PlayInfo for TTS
+ playInfo := &models.PlayInfo{
+ URL: ttsURL,
+ AppKey: appKey,
+ Service: "TTS Notification",
+ Message: "Google TTS",
+ Reason: text,
+ }
+
+ if volume > 0 {
+ playInfo.SetVolume(volume)
+ }
+
+ err = client.PlayCustom(playInfo)
+ if err != nil {
+ PrintError(fmt.Sprintf("Failed to play TTS message: %v", err))
+ return err
+ }
+
+ fmt.Printf("✅ TTS message sent successfully\n")
+
+ if volume > 0 {
+ fmt.Printf(" Volume: %d\n", volume)
+ } else {
+ fmt.Printf(" Volume: current level\n")
+ }
+
+ fmt.Printf(" Language: %s\n", strings.ToUpper(language))
+ fmt.Printf(" Message: \"%s\"\n", text)
+
+ return nil
+}
+
+// playURL plays audio content from a URL on the speaker
+func playURL(c *cli.Context) error {
+ clientConfig := GetClientConfig(c)
+ urlStr := c.String("url")
+ appKey := c.String("app-key")
+ service := c.String("service")
+ message := c.String("message")
+ reason := c.String("reason")
+ volume := c.Int("volume")
+
+ if urlStr == "" {
+ PrintError("URL is required")
+ return fmt.Errorf("URL cannot be empty")
+ }
+
+ if appKey == "" {
+ PrintError("App key is required")
+ return fmt.Errorf("app key cannot be empty")
+ }
+
+ // Set defaults if not provided
+ if service == "" {
+ service = "URL Playback"
+ }
+
+ if message == "" {
+ message = "Audio Content"
+ }
+
+ if reason == "" {
+ // Extract filename or use URL as reason
+ if idx := strings.LastIndex(urlStr, "/"); idx != -1 && idx < len(urlStr)-1 {
+ reason = urlStr[idx+1:]
+ } else {
+ reason = urlStr
+ }
+ }
+
+ PrintDeviceHeader(fmt.Sprintf("Playing URL: %s", urlStr), clientConfig.Host, clientConfig.Port)
+
+ client, err := CreateSoundTouchClient(clientConfig)
+ if err != nil {
+ PrintError(fmt.Sprintf("Failed to create client: %v", err))
+ return err
+ }
+
+ // Create PlayInfo for URL content
+ playInfo := models.NewURLPlayInfo(urlStr, appKey, service, message, reason)
+
+ if volume > 0 {
+ playInfo.SetVolume(volume)
+ }
+
+ err = client.PlayCustom(playInfo)
+ if err != nil {
+ PrintError(fmt.Sprintf("Failed to play URL content: %v", err))
+ return err
+ }
+
+ fmt.Printf("✅ URL playback started successfully\n")
+ fmt.Printf(" URL: %s\n", urlStr)
+ fmt.Printf(" Service: %s\n", service)
+ fmt.Printf(" Message: %s\n", message)
+
+ if volume > 0 {
+ fmt.Printf(" Volume: %d\n", volume)
+ } else {
+ fmt.Printf(" Volume: current level\n")
+ }
+
+ return nil
+}
+
+// playNotificationBeep plays a notification beep on the speaker (uses existing endpoint)
+func playNotificationBeep(c *cli.Context) error {
+ clientConfig := GetClientConfig(c)
+ PrintDeviceHeader("Playing notification beep", clientConfig.Host, clientConfig.Port)
+
+ client, err := CreateSoundTouchClient(clientConfig)
+ if err != nil {
+ PrintError(fmt.Sprintf("Failed to create client: %v", err))
+ return err
+ }
+
+ // Use the existing playNotification endpoint
+ err = client.PlayNotificationBeep()
+ if err != nil {
+ PrintError(fmt.Sprintf("Failed to play notification beep: %v", err))
+ return err
+ }
+
+ fmt.Printf("✅ Notification beep played successfully\n")
+
+ return nil
+}
+
+// showSpeakerHelp displays help information about speaker functionality
+func showSpeakerHelp(_ *cli.Context) error {
+ fmt.Println("SoundTouch Speaker Playback Commands")
+ fmt.Println("=====================================")
+ fmt.Println()
+ fmt.Println("The /speaker endpoint supports playing notifications and URL content:")
+ fmt.Println()
+ fmt.Println("• Text-to-Speech (TTS) Messages:")
+ fmt.Println(" Play spoken messages using Google TTS")
+ fmt.Println(" Example: soundtouch-cli speaker tts --text \"Hello World\" --app-key YOUR_KEY")
+ fmt.Println()
+ fmt.Println("• URL Content Playback:")
+ fmt.Println(" Play audio files from HTTP/HTTPS URLs")
+ fmt.Println(" Example: soundtouch-cli speaker url --url \"https://example.com/audio.mp3\" --app-key YOUR_KEY")
+ fmt.Println()
+ fmt.Println("• Notification Beep:")
+ fmt.Println(" Play a simple notification sound")
+ fmt.Println(" Example: soundtouch-cli speaker beep")
+ fmt.Println()
+ fmt.Println("Notes:")
+ fmt.Println("• Only ST-10 (Series III) speakers support the /speaker endpoint")
+ fmt.Println("• ST-300 and other models may not support this functionality")
+ fmt.Println("• You need to provide your own app_key for TTS and URL playback")
+ fmt.Println("• Currently playing content is paused during playback and resumed after")
+ fmt.Println("• If device is a zone master, content plays on all zone members")
+ fmt.Println("• Volume is automatically restored after playback completes")
+ fmt.Println()
+ fmt.Println("Supported Languages for TTS:")
+ fmt.Println("EN (English), DE (German), ES (Spanish), FR (French), IT (Italian),")
+ fmt.Println("NL (Dutch), PT (Portuguese), RU (Russian), ZH (Chinese), JA (Japanese)")
+
+ return nil
+}
diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go
index 5ecb617..e130d44 100644
--- a/cmd/soundtouch-cli/main.go
+++ b/cmd/soundtouch-cli/main.go
@@ -1392,6 +1392,100 @@ func main() {
},
},
},
+ // Speaker commands (TTS and URL playback)
+ {
+ Name: "speaker",
+ Aliases: []string{"sp"},
+ Usage: "Speaker notification and content playback commands",
+ Subcommands: []*cli.Command{
+ {
+ Name: "tts",
+ Usage: "Play a Text-To-Speech message",
+ Action: playTTS,
+ Before: RequireHost,
+ Flags: []cli.Flag{
+ &cli.StringFlag{
+ Name: "text",
+ Aliases: []string{"t"},
+ Usage: "Text message to speak",
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "app-key",
+ Aliases: []string{"k"},
+ Usage: "Application key for the request",
+ Required: true,
+ },
+ &cli.IntFlag{
+ Name: "volume",
+ Aliases: []string{"v"},
+ Usage: "Volume level (0-100, 0 = current volume)",
+ Value: 0,
+ },
+ &cli.StringFlag{
+ Name: "language",
+ Aliases: []string{"l"},
+ Usage: "Language code (EN, DE, ES, FR, etc.)",
+ Value: "EN",
+ },
+ },
+ },
+ {
+ Name: "url",
+ Usage: "Play audio content from a URL",
+ Action: playURL,
+ Before: RequireHost,
+ Flags: []cli.Flag{
+ &cli.StringFlag{
+ Name: "url",
+ Aliases: []string{"u"},
+ Usage: "URL of the audio content to play",
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "app-key",
+ Aliases: []string{"k"},
+ Usage: "Application key for the request",
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "service",
+ Aliases: []string{"s"},
+ Usage: "Service name (appears in NowPlaying artist field)",
+ Value: "URL Playback",
+ },
+ &cli.StringFlag{
+ Name: "message",
+ Aliases: []string{"m"},
+ Usage: "Message description (appears in NowPlaying album field)",
+ Value: "Audio Content",
+ },
+ &cli.StringFlag{
+ Name: "reason",
+ Aliases: []string{"r"},
+ Usage: "Reason or filename (appears in NowPlaying track field)",
+ },
+ &cli.IntFlag{
+ Name: "volume",
+ Aliases: []string{"v"},
+ Usage: "Volume level (0-100, 0 = current volume)",
+ Value: 0,
+ },
+ },
+ },
+ {
+ Name: "beep",
+ Usage: "Play a notification beep sound",
+ Action: playNotificationBeep,
+ Before: RequireHost,
+ },
+ {
+ Name: "help",
+ Usage: "Show detailed help about speaker functionality",
+ Action: showSpeakerHelp,
+ },
+ },
+ },
// Token commands
{
Name: "token",
diff --git a/pkg/client/client.go b/pkg/client/client.go
index b5f3ab2..e84abd6 100644
--- a/pkg/client/client.go
+++ b/pkg/client/client.go
@@ -1643,3 +1643,44 @@ func (c *Client) hasCapability(capabilities *models.Capabilities, capability str
capStr := fmt.Sprintf("%+v", capabilities)
return strings.Contains(capStr, capability)
}
+
+// PlayTTS plays a Text-To-Speech message using Google TTS on the speaker
+func (c *Client) PlayTTS(text, appKey string, volume ...int) error {
+ playInfo := models.NewTTSPlayInfo(text, appKey, volume...)
+
+ if err := playInfo.Validate(); err != nil {
+ return fmt.Errorf("invalid TTS request: %w", err)
+ }
+
+ return c.postPlayInfo(playInfo)
+}
+
+// PlayURL plays audio content from a URL on the speaker
+func (c *Client) PlayURL(url, appKey, service, message, reason string, volume ...int) error {
+ playInfo := models.NewURLPlayInfo(url, appKey, service, message, reason, volume...)
+
+ if err := playInfo.Validate(); err != nil {
+ return fmt.Errorf("invalid URL play request: %w", err)
+ }
+
+ return c.postPlayInfo(playInfo)
+}
+
+// PlayCustom plays custom content using a PlayInfo configuration
+func (c *Client) PlayCustom(playInfo *models.PlayInfo) error {
+ if err := playInfo.Validate(); err != nil {
+ return fmt.Errorf("invalid play request: %w", err)
+ }
+
+ return c.postPlayInfo(playInfo)
+}
+
+// PlayNotificationBeep plays a notification beep on the device
+func (c *Client) PlayNotificationBeep() error {
+ return c.post("/playNotification", nil)
+}
+
+// postPlayInfo sends a PlayInfo request to the /speaker endpoint
+func (c *Client) postPlayInfo(playInfo *models.PlayInfo) error {
+ return c.post("/speaker", playInfo)
+}
diff --git a/pkg/models/speaker.go b/pkg/models/speaker.go
new file mode 100644
index 0000000..3f41d87
--- /dev/null
+++ b/pkg/models/speaker.go
@@ -0,0 +1,127 @@
+package models
+
+import (
+ "encoding/xml"
+ "errors"
+)
+
+// Error constants for speaker validation
+var (
+ ErrInvalidURL = errors.New("URL cannot be empty")
+ ErrInvalidAppKey = errors.New("app key cannot be empty")
+ ErrInvalidService = errors.New("service cannot be empty")
+ ErrInvalidVolume = errors.New("volume must be between 0 and 100")
+)
+
+// PlayInfo represents the request body for the /speaker endpoint to play TTS or URL content
+type PlayInfo struct {
+ XMLName xml.Name `xml:"play_info"`
+ URL string `xml:"url"`
+ AppKey string `xml:"app_key"`
+ Service string `xml:"service"`
+ Message string `xml:"message"`
+ Reason string `xml:"reason"`
+ Volume *int `xml:"volume,omitempty"`
+}
+
+// SpeakerResponse represents the response from the /speaker endpoint
+type SpeakerResponse struct {
+ XMLName xml.Name `xml:"status"`
+ Value string `xml:",chardata"`
+}
+
+// SpeakerPlayStatus represents the status during speaker playback
+type SpeakerPlayStatus struct {
+ Service string `json:"service"`
+ Message string `json:"message"`
+ Reason string `json:"reason"`
+ Volume int `json:"volume,omitempty"`
+}
+
+// NewPlayInfo creates a new PlayInfo instance for TTS or URL playback
+func NewPlayInfo(url, appKey, service, message, reason string) *PlayInfo {
+ return &PlayInfo{
+ XMLName: xml.Name{Local: "play_info"},
+ URL: url,
+ AppKey: appKey,
+ Service: service,
+ Message: message,
+ Reason: reason,
+ }
+}
+
+// SetVolume sets the volume level for playback
+func (p *PlayInfo) SetVolume(volume int) *PlayInfo {
+ p.Volume = &volume
+ return p
+}
+
+// NewTTSPlayInfo creates a PlayInfo for Google TTS playback
+func NewTTSPlayInfo(text, appKey string, volume ...int) *PlayInfo {
+ // URL encode the text for Google TTS
+ url := "http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=" + text
+
+ playInfo := &PlayInfo{
+ XMLName: xml.Name{Local: "play_info"},
+ URL: url,
+ AppKey: appKey,
+ Service: "TTS Notification",
+ Message: "Google TTS",
+ Reason: text,
+ }
+
+ if len(volume) > 0 {
+ playInfo.Volume = &volume[0]
+ }
+
+ return playInfo
+}
+
+// NewURLPlayInfo creates a PlayInfo for URL content playback
+func NewURLPlayInfo(url, appKey, service, message, reason string, volume ...int) *PlayInfo {
+ playInfo := &PlayInfo{
+ XMLName: xml.Name{Local: "play_info"},
+ URL: url,
+ AppKey: appKey,
+ Service: service,
+ Message: message,
+ Reason: reason,
+ }
+
+ if len(volume) > 0 {
+ playInfo.Volume = &volume[0]
+ }
+
+ return playInfo
+}
+
+// Validate validates the PlayInfo request
+func (p *PlayInfo) Validate() error {
+ if p.URL == "" {
+ return ErrInvalidURL
+ }
+
+ if p.AppKey == "" {
+ return ErrInvalidAppKey
+ }
+
+ if p.Service == "" {
+ return ErrInvalidService
+ }
+
+ if p.Volume != nil && (*p.Volume < 0 || *p.Volume > 100) {
+ return ErrInvalidVolume
+ }
+
+ return nil
+}
+
+// String returns a string representation of the PlayInfo
+func (p *PlayInfo) String() string {
+ volumeStr := "current"
+ if p.Volume != nil {
+ volumeStr = string(rune(*p.Volume))
+ }
+
+ return "Service: " + p.Service + ", Message: " + p.Message + ", Volume: " + volumeStr
+}
diff --git a/pkg/models/speaker_test.go b/pkg/models/speaker_test.go
new file mode 100644
index 0000000..31ac368
--- /dev/null
+++ b/pkg/models/speaker_test.go
@@ -0,0 +1,331 @@
+package models
+
+import (
+ "encoding/xml"
+ "testing"
+)
+
+func TestNewPlayInfo(t *testing.T) {
+ playInfo := NewPlayInfo("https://example.com/audio.mp3", "test-key", "Test Service", "Test Message", "Test Reason")
+
+ if playInfo.URL != "https://example.com/audio.mp3" {
+ t.Errorf("Expected URL 'https://example.com/audio.mp3', got '%s'", playInfo.URL)
+ }
+
+ if playInfo.AppKey != "test-key" {
+ t.Errorf("Expected AppKey 'test-key', got '%s'", playInfo.AppKey)
+ }
+
+ if playInfo.Service != "Test Service" {
+ t.Errorf("Expected Service 'Test Service', got '%s'", playInfo.Service)
+ }
+
+ if playInfo.Message != "Test Message" {
+ t.Errorf("Expected Message 'Test Message', got '%s'", playInfo.Message)
+ }
+
+ if playInfo.Reason != "Test Reason" {
+ t.Errorf("Expected Reason 'Test Reason', got '%s'", playInfo.Reason)
+ }
+
+ if playInfo.Volume != nil {
+ t.Errorf("Expected Volume to be nil, got %v", *playInfo.Volume)
+ }
+}
+
+func TestNewTTSPlayInfo(t *testing.T) {
+ // Test without volume
+ playInfo := NewTTSPlayInfo("Hello World", "test-key")
+
+ expectedURL := "http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=Hello World"
+ if playInfo.URL != expectedURL {
+ t.Errorf("Expected URL '%s', got '%s'", expectedURL, playInfo.URL)
+ }
+
+ if playInfo.AppKey != "test-key" {
+ t.Errorf("Expected AppKey 'test-key', got '%s'", playInfo.AppKey)
+ }
+
+ if playInfo.Service != "TTS Notification" {
+ t.Errorf("Expected Service 'TTS Notification', got '%s'", playInfo.Service)
+ }
+
+ if playInfo.Message != "Google TTS" {
+ t.Errorf("Expected Message 'Google TTS', got '%s'", playInfo.Message)
+ }
+
+ if playInfo.Reason != "Hello World" {
+ t.Errorf("Expected Reason 'Hello World', got '%s'", playInfo.Reason)
+ }
+
+ if playInfo.Volume != nil {
+ t.Errorf("Expected Volume to be nil, got %v", *playInfo.Volume)
+ }
+
+ // Test with volume
+ playInfoWithVolume := NewTTSPlayInfo("Hello World", "test-key", 50)
+ if playInfoWithVolume.Volume == nil || *playInfoWithVolume.Volume != 50 {
+ t.Errorf("Expected Volume to be 50, got %v", playInfoWithVolume.Volume)
+ }
+}
+
+func TestNewURLPlayInfo(t *testing.T) {
+ // Test without volume
+ playInfo := NewURLPlayInfo(
+ "https://example.com/audio.mp3",
+ "test-key",
+ "Music Service",
+ "Song Title",
+ "Artist Name",
+ )
+
+ if playInfo.URL != "https://example.com/audio.mp3" {
+ t.Errorf("Expected URL 'https://example.com/audio.mp3', got '%s'", playInfo.URL)
+ }
+
+ if playInfo.Service != "Music Service" {
+ t.Errorf("Expected Service 'Music Service', got '%s'", playInfo.Service)
+ }
+
+ if playInfo.Message != "Song Title" {
+ t.Errorf("Expected Message 'Song Title', got '%s'", playInfo.Message)
+ }
+
+ if playInfo.Reason != "Artist Name" {
+ t.Errorf("Expected Reason 'Artist Name', got '%s'", playInfo.Reason)
+ }
+
+ // Test with volume
+ playInfoWithVolume := NewURLPlayInfo(
+ "https://example.com/audio.mp3",
+ "test-key",
+ "Music Service",
+ "Song Title",
+ "Artist Name",
+ 75,
+ )
+
+ if playInfoWithVolume.Volume == nil || *playInfoWithVolume.Volume != 75 {
+ t.Errorf("Expected Volume to be 75, got %v", playInfoWithVolume.Volume)
+ }
+}
+
+func TestSetVolume(t *testing.T) {
+ playInfo := NewPlayInfo("https://example.com/audio.mp3", "test-key", "Service", "Message", "Reason")
+
+ // Set volume and check fluent interface
+ result := playInfo.SetVolume(60)
+
+ // Check that it returns the same instance (fluent interface)
+ if result != playInfo {
+ t.Error("SetVolume should return the same instance for fluent interface")
+ }
+
+ // Check that volume was set correctly
+ if playInfo.Volume == nil || *playInfo.Volume != 60 {
+ t.Errorf("Expected Volume to be 60, got %v", playInfo.Volume)
+ }
+}
+
+func TestPlayInfoValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ playInfo *PlayInfo
+ expectedErr error
+ }{
+ {
+ name: "valid PlayInfo",
+ playInfo: &PlayInfo{
+ URL: "https://example.com/audio.mp3",
+ AppKey: "test-key",
+ Service: "Test Service",
+ Message: "Test Message",
+ Reason: "Test Reason",
+ },
+ expectedErr: nil,
+ },
+ {
+ name: "empty URL",
+ playInfo: &PlayInfo{
+ URL: "",
+ AppKey: "test-key",
+ Service: "Test Service",
+ },
+ expectedErr: ErrInvalidURL,
+ },
+ {
+ name: "empty AppKey",
+ playInfo: &PlayInfo{
+ URL: "https://example.com/audio.mp3",
+ AppKey: "",
+ Service: "Test Service",
+ },
+ expectedErr: ErrInvalidAppKey,
+ },
+ {
+ name: "empty Service",
+ playInfo: &PlayInfo{
+ URL: "https://example.com/audio.mp3",
+ AppKey: "test-key",
+ Service: "",
+ },
+ expectedErr: ErrInvalidService,
+ },
+ {
+ name: "negative volume",
+ playInfo: &PlayInfo{
+ URL: "https://example.com/audio.mp3",
+ AppKey: "test-key",
+ Service: "Test Service",
+ Volume: intPtr(-1),
+ },
+ expectedErr: ErrInvalidVolume,
+ },
+ {
+ name: "volume too high",
+ playInfo: &PlayInfo{
+ URL: "https://example.com/audio.mp3",
+ AppKey: "test-key",
+ Service: "Test Service",
+ Volume: intPtr(101),
+ },
+ expectedErr: ErrInvalidVolume,
+ },
+ {
+ name: "valid volume at boundary",
+ playInfo: &PlayInfo{
+ URL: "https://example.com/audio.mp3",
+ AppKey: "test-key",
+ Service: "Test Service",
+ Volume: intPtr(100),
+ },
+ expectedErr: nil,
+ },
+ {
+ name: "valid volume at zero boundary",
+ playInfo: &PlayInfo{
+ URL: "https://example.com/audio.mp3",
+ AppKey: "test-key",
+ Service: "Test Service",
+ Volume: intPtr(0),
+ },
+ expectedErr: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.playInfo.Validate()
+ if (err != nil && tt.expectedErr == nil) || (err == nil && tt.expectedErr != nil) || (err != nil && tt.expectedErr != nil && err.Error() != tt.expectedErr.Error()) {
+ t.Errorf("Expected error %v, got %v", tt.expectedErr, err)
+ }
+ })
+ }
+}
+
+func TestPlayInfoString(t *testing.T) {
+ // Test without volume
+ playInfo := &PlayInfo{
+ Service: "Test Service",
+ Message: "Test Message",
+ }
+
+ expected := "Service: Test Service, Message: Test Message, Volume: current"
+ result := playInfo.String()
+
+ if result != expected {
+ t.Errorf("Expected '%s', got '%s'", expected, result)
+ }
+
+ // Test with volume
+ playInfo.SetVolume(75)
+
+ expectedWithVolume := "Service: Test Service, Message: Test Message, Volume: K" // K is ASCII 75
+ resultWithVolume := playInfo.String()
+
+ if resultWithVolume != expectedWithVolume {
+ t.Errorf("Expected '%s', got '%s'", expectedWithVolume, resultWithVolume)
+ }
+}
+
+func TestPlayInfoXMLMarshaling(t *testing.T) {
+ // Test XML marshaling
+ playInfo := &PlayInfo{
+ URL: "https://example.com/audio.mp3",
+ AppKey: "test-key",
+ Service: "Test Service",
+ Message: "Test Message",
+ Reason: "Test Reason",
+ Volume: intPtr(50),
+ }
+
+ xmlData, err := xml.Marshal(playInfo)
+ if err != nil {
+ t.Fatalf("Failed to marshal PlayInfo to XML: %v", err)
+ }
+
+ expectedXML := `https://example.com/audio.mp3test-keyTest ServiceTest MessageTest Reason50`
+ if string(xmlData) != expectedXML {
+ t.Errorf("Expected XML '%s', got '%s'", expectedXML, string(xmlData))
+ }
+
+ // Test XML unmarshaling
+ var unmarshaled PlayInfo
+
+ err = xml.Unmarshal(xmlData, &unmarshaled)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal PlayInfo from XML: %v", err)
+ }
+
+ if unmarshaled.URL != playInfo.URL {
+ t.Errorf("Expected URL '%s', got '%s'", playInfo.URL, unmarshaled.URL)
+ }
+
+ if unmarshaled.AppKey != playInfo.AppKey {
+ t.Errorf("Expected AppKey '%s', got '%s'", playInfo.AppKey, unmarshaled.AppKey)
+ }
+
+ if unmarshaled.Service != playInfo.Service {
+ t.Errorf("Expected Service '%s', got '%s'", playInfo.Service, unmarshaled.Service)
+ }
+
+ if unmarshaled.Volume == nil || *unmarshaled.Volume != *playInfo.Volume {
+ t.Errorf("Expected Volume %v, got %v", playInfo.Volume, unmarshaled.Volume)
+ }
+}
+
+func TestSpeakerResponse(t *testing.T) {
+ // Test XML marshaling
+ response := &SpeakerResponse{
+ Value: "/speaker",
+ }
+
+ xmlData, err := xml.Marshal(response)
+ if err != nil {
+ t.Fatalf("Failed to marshal SpeakerResponse to XML: %v", err)
+ }
+
+ expectedXML := `/speaker`
+ if string(xmlData) != expectedXML {
+ t.Errorf("Expected XML '%s', got '%s'", expectedXML, string(xmlData))
+ }
+
+ // Test XML unmarshaling
+ xmlInput := `/speaker`
+
+ var unmarshaled SpeakerResponse
+
+ err = xml.Unmarshal([]byte(xmlInput), &unmarshaled)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal SpeakerResponse from XML: %v", err)
+ }
+
+ if unmarshaled.Value != "/speaker" {
+ t.Errorf("Expected Value '/speaker', got '%s'", unmarshaled.Value)
+ }
+}
+
+// Helper function to create int pointer for tests
+func intPtr(i int) *int {
+ return &i
+}