mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user