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) + } +}