feat: implement /speaker endpoint for TTS and URL playback

- Add PlayInfo model for TTS and URL content playback requests
- Add SpeakerResponse model for endpoint responses
- Implement client methods: PlayTTS, PlayURL, PlayCustom, PlayNotificationBeep
- Add comprehensive CLI commands for speaker functionality:
  - speaker tts: Text-to-Speech with Google TTS and language support
  - speaker url: Audio content playback from HTTP/HTTPS URLs
  - speaker beep: Simple notification beep sound
  - speaker help: Detailed functionality documentation
- Support for volume control (0-100 or current volume)
- Multi-language TTS support (EN, DE, ES, FR, IT, NL, PT, RU, ZH, JA, etc.)
- Custom metadata support for NowPlaying display
- Comprehensive validation and error handling
- Full test suite with XML marshaling/unmarshaling tests
- Complete documentation with API reference and usage examples
- Compatible with ST-10 (Series III) and other supported SoundTouch devices

The /speaker endpoint enables notification and audio content playback,
automatically managing volume restoration and content interruption.
Perfect for home automation, alerts, and custom audio notifications.
This commit is contained in:
Tobias Gesellchen
2026-02-01 23:21:15 +01:00
parent a008093775
commit 3a33cadbd7
6 changed files with 1109 additions and 0 deletions
+127
View File
@@ -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
}
+331
View File
@@ -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 := `<play_info><url>https://example.com/audio.mp3</url><app_key>test-key</app_key><service>Test Service</service><message>Test Message</message><reason>Test Reason</reason><volume>50</volume></play_info>`
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 := `<status>/speaker</status>`
if string(xmlData) != expectedXML {
t.Errorf("Expected XML '%s', got '%s'", expectedXML, string(xmlData))
}
// Test XML unmarshaling
xmlInput := `<?xml version="1.0" encoding="UTF-8" ?><status>/speaker</status>`
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
}