diff --git a/cmd/soundtouch-cli/cmd_speaker.go b/cmd/soundtouch-cli/cmd_speaker.go
index 5bdf9d0..2ee86db 100644
--- a/cmd/soundtouch-cli/cmd_speaker.go
+++ b/cmd/soundtouch-cli/cmd_speaker.go
@@ -147,10 +147,16 @@ func playURL(c *cli.Context) error {
return nil
}
-// playNotificationBeep plays a notification beep on the speaker (uses existing endpoint)
-func playNotificationBeep(c *cli.Context) error {
+// playNotification plays a notification sound or a local file on the speaker
+func playNotification(c *cli.Context) error {
clientConfig := GetClientConfig(c)
- PrintDeviceHeader("Playing notification beep", clientConfig.Host, clientConfig.Port)
+ path := c.String("path")
+
+ if path != "" {
+ PrintDeviceHeader(fmt.Sprintf("Playing notification file: %s", path), clientConfig.Host, clientConfig.Port)
+ } else {
+ PrintDeviceHeader("Playing notification beep", clientConfig.Host, clientConfig.Port)
+ }
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
@@ -158,18 +164,30 @@ func playNotificationBeep(c *cli.Context) error {
return err
}
- // Use the existing playNotification endpoint
- err = client.PlayNotificationBeep()
+ err = client.PlayNotification(path)
if err != nil {
- PrintError(fmt.Sprintf("Failed to play notification beep: %v", err))
+ if path != "" {
+ PrintError(fmt.Sprintf("Failed to play notification file: %v", err))
+ } else {
+ PrintError(fmt.Sprintf("Failed to play notification beep: %v", err))
+ }
return err
}
- fmt.Printf("✅ Notification beep played successfully\n")
+ if path != "" {
+ fmt.Printf("✅ Notification file sent successfully: %s\n", path)
+ } else {
+ fmt.Printf("✅ Notification beep played successfully\n")
+ }
return nil
}
+// playNotificationBeep plays a notification beep on the speaker (uses existing endpoint)
+func playNotificationBeep(c *cli.Context) error {
+ return playNotification(c)
+}
+
// showSpeakerHelp displays help information about speaker functionality
func showSpeakerHelp(_ *cli.Context) error {
fmt.Println("SoundTouch Speaker Playback Commands")
@@ -189,6 +207,10 @@ func showSpeakerHelp(_ *cli.Context) error {
fmt.Println(" Play a simple notification sound")
fmt.Println(" Example: soundtouch-cli speaker beep")
fmt.Println()
+ fmt.Println("• Custom Notification:")
+ fmt.Println(" Play a device-local PCM file as notification")
+ fmt.Println(" Example: soundtouch-cli speaker notify --path \"/opt/Bose/chimes/grouped.pcm\"")
+ 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")
diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go
index 00cd1a5..8cf25ad 100644
--- a/cmd/soundtouch-cli/main.go
+++ b/cmd/soundtouch-cli/main.go
@@ -1707,6 +1707,19 @@ func main() {
},
},
},
+ {
+ Name: "notify",
+ Usage: "Play a notification sound or local file",
+ Action: playNotification,
+ Before: RequireHost,
+ Flags: []cli.Flag{
+ &cli.StringFlag{
+ Name: "path",
+ Aliases: []string{"p"},
+ Usage: "Device-local path to a PCM file (e.g. /opt/Bose/chimes/grouped.pcm)",
+ },
+ },
+ },
{
Name: "beep",
Usage: "Play a notification beep sound",
diff --git a/pkg/client/client.go b/pkg/client/client.go
index e2f6ba4..ee130cc 100644
--- a/pkg/client/client.go
+++ b/pkg/client/client.go
@@ -1801,8 +1801,29 @@ func (c *Client) PlayCustom(playInfo *models.PlayInfo) error {
// PlayNotificationBeep plays a notification beep on the device
func (c *Client) PlayNotificationBeep() error {
- var status models.StationResponse
- return c.get("/playNotification", &status)
+ return c.PlayNotification("")
+}
+
+// PlayNotification plays a notification. If a non-empty local path is provided,
+// it will be sent as XML body to play that specific device-local PCM file.
+// When path is empty, the device's default beep is triggered.
+func (c *Client) PlayNotification(path string) error {
+ // Empty path -> trigger default beep via GET
+ if strings.TrimSpace(path) == "" {
+ var status models.StationResponse
+ return c.get("/playNotification", &status)
+ }
+
+ // Non-empty path -> POST minimal XML payload as required by the device
+ payload := struct {
+ XMLName xml.Name `xml:"audioSource"`
+ PathToFile string `xml:"pathToFile,attr"`
+ }{
+ XMLName: xml.Name{Local: "audioSource"},
+ PathToFile: path,
+ }
+
+ return c.post("/playNotification", payload)
}
// Introspect retrieves introspect data for a specified music service
diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go
index 98dbb4d..e5dfab8 100644
--- a/pkg/client/client_test.go
+++ b/pkg/client/client_test.go
@@ -1,6 +1,7 @@
package client
import (
+ "io"
"net/http"
"net/http/httptest"
"net/url"
@@ -1160,3 +1161,71 @@ func TestClient_RequestToken_Error(t *testing.T) {
t.Errorf("Error should mention 'failed to request token', got: %v", err)
}
}
+
+func TestClient_PlayNotificationBeep(t *testing.T) {
+ // Create mock server
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/playNotification" {
+ t.Errorf("Expected path '/playNotification', got '%s'", r.URL.Path)
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+
+ if r.Method != http.MethodGet {
+ t.Errorf("Expected GET method, got %s", r.Method)
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`success`))
+ }))
+ defer server.Close()
+
+ // Create test client
+ client := createTestClient(server.URL)
+
+ // Test PlayNotificationBeep
+ err := client.PlayNotificationBeep()
+ if err != nil {
+ t.Fatalf("PlayNotificationBeep() failed: %v", err)
+ }
+}
+
+func TestClient_PlayNotification_Path(t *testing.T) {
+ testPath := "/opt/Bose/chimes/grouped.pcm"
+
+ // Create mock server
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/playNotification" {
+ t.Errorf("Expected path '/playNotification', got '%s'", r.URL.Path)
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+
+ if r.Method != http.MethodPost {
+ t.Errorf("Expected POST method, got %s", r.Method)
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+
+ body, _ := io.ReadAll(r.Body)
+ expectedXML := ``
+ if string(body) != expectedXML {
+ t.Errorf("Expected body '%s', got '%s'", expectedXML, string(body))
+ }
+
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ // Create test client
+ client := createTestClient(server.URL)
+
+ // Test PlayNotification with path
+ err := client.PlayNotification(testPath)
+ if err != nil {
+ t.Fatalf("PlayNotification() failed: %v", err)
+ }
+}