diff --git a/CHANGELOG.md b/CHANGELOG.md index d5682fe..ac8694e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Fixed +- `sonos scene save` fails when a room's volume or mute cannot be read, instead of storing volume 0 and mute off. + ## [0.3.5] - 2026-10-23 **Highlights:** Fix the reachable crypto/x509 vulnerability GO-2026-6809 with Go 1.27.3, explain SMAPI API-client identity rejections, and correctly label initial watch events. diff --git a/docs/scenes.md b/docs/scenes.md index d533325..18c22be 100644 --- a/docs/scenes.md +++ b/docs/scenes.md @@ -13,7 +13,7 @@ A **scene** is a named snapshot of your Sonos system: which speakers are grouped sonos scene save evening ``` -This walks the live topology, snapshots every visible zone (and its members, volume, and mute), and stores the scene under your config directory. +This walks the live topology, snapshots every visible zone (and its members, volume, and mute), and stores the scene under your config directory. If a visible room does not answer its volume or mute read, save stops and leaves any previously stored scene unchanged. A reported volume of 0 is still stored. ## List, apply, delete diff --git a/internal/cli/scene.go b/internal/cli/scene.go index 3397adb..2153ae4 100644 --- a/internal/cli/scene.go +++ b/internal/cli/scene.go @@ -165,8 +165,14 @@ func newSceneSaveCmd(flags *rootFlags) *cobra.Command { } seen[m.UUID] = true c := newSceneSpeakerClient(m.IP, flags.Timeout) - vol, _ := c.GetVolume(cmd.Context()) - mute, _ := c.GetMute(cmd.Context()) + vol, err := c.GetVolume(cmd.Context()) + if err != nil { + return fmt.Errorf("get volume for %s: %w", m.Name, err) + } + mute, err := c.GetMute(cmd.Context()) + if err != nil { + return fmt.Errorf("get mute for %s: %w", m.Name, err) + } scene.Devices = append(scene.Devices, scenes.SceneDevice{ UUID: m.UUID, Name: m.Name, diff --git a/internal/cli/scene_test.go b/internal/cli/scene_test.go index 7aaf01a..41c8e80 100644 --- a/internal/cli/scene_test.go +++ b/internal/cli/scene_test.go @@ -74,6 +74,9 @@ type fakeSceneSpeaker struct { setMuteCalls int setMuteValue bool + + volumeErr error + muteErr error } func (f *fakeSceneSpeaker) LeaveGroup(ctx context.Context) error { @@ -87,13 +90,20 @@ func (f *fakeSceneSpeaker) JoinGroup(ctx context.Context, coordinatorUUID string return nil } -func (f *fakeSceneSpeaker) GetVolume(ctx context.Context) (int, error) { return f.volume, nil } +func (f *fakeSceneSpeaker) GetVolume(ctx context.Context) (int, error) { + return f.volume, f.volumeErr +} + func (f *fakeSceneSpeaker) SetVolume(ctx context.Context, volume int) error { f.setVolCalls++ f.setVolValue = volume return nil } -func (f *fakeSceneSpeaker) GetMute(ctx context.Context) (bool, error) { return f.mute, nil } + +func (f *fakeSceneSpeaker) GetMute(ctx context.Context) (bool, error) { + return f.mute, f.muteErr +} + func (f *fakeSceneSpeaker) SetMute(ctx context.Context, mute bool) error { f.setMuteCalls++ f.setMuteValue = mute @@ -178,6 +188,142 @@ func TestSceneSaveCapturesGroupsAndDeviceAudio(t *testing.T) { } } +func TestSceneSaveDoesNotStoreZeroWhenLevelReadFails(t *testing.T) { + flags := &rootFlags{Timeout: 2 * time.Second} + top := sonos.Topology{ + Groups: []sonos.Group{ + { + ID: "G1", + Coordinator: sonos.Member{ + Name: "Living Room", IP: "192.168.1.10", UUID: "RINCON_LR1400", + IsCoordinator: true, IsVisible: true, + }, + Members: []sonos.Member{ + {Name: "Living Room", IP: "192.168.1.10", UUID: "RINCON_LR1400", IsCoordinator: true, IsVisible: true}, + {Name: "Kitchen", IP: "192.168.1.11", UUID: "RINCON_K1400", IsVisible: true}, + }, + }, + }, + } + + origStore := newSceneStore + origTG := newSceneTopologyGetter + origClient := newSceneSpeakerClient + t.Cleanup(func() { + newSceneStore = origStore + newSceneTopologyGetter = origTG + newSceneSpeakerClient = origClient + }) + newSceneTopologyGetter = func(ctx context.Context, timeout time.Duration) (sceneTopologyGetter, error) { + return &fakeSceneTopologyGetter{top: top}, nil + } + + cases := []struct { + name string + speaker *fakeSceneSpeaker + want string + }{ + { + name: "volume", + speaker: &fakeSceneSpeaker{ip: "192.168.1.11", volumeErr: errors.New("timeout")}, + want: "get volume for Kitchen", + }, + { + name: "mute", + speaker: &fakeSceneSpeaker{ip: "192.168.1.11", volume: 20, muteErr: errors.New("timeout")}, + want: "get mute for Kitchen", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + store := &fakeSceneStore{} + speakers := map[string]*fakeSceneSpeaker{ + "192.168.1.10": {ip: "192.168.1.10", volume: 15}, + "192.168.1.11": tc.speaker, + } + newSceneStore = func() (scenes.Store, error) { return store, nil } + newSceneSpeakerClient = func(ip string, timeout time.Duration) sceneSpeakerClient { + return speakers[ip] + } + + cmd := newSceneCmd(flags) + cmd.SetOut(newDiscardWriter()) + cmd.SetErr(newDiscardWriter()) + cmd.SetArgs([]string{"save", "Morning"}) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + err := cmd.ExecuteContext(context.Background()) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want %q", err, tc.want) + } + if store.put.Name != "" { + t.Fatalf("stored scene %q with volume %d mute %v", store.put.Name, firstVolume(store.put), firstMute(store.put)) + } + }) + } +} + +func TestSceneSaveKeepsAReportedZeroVolume(t *testing.T) { + flags := &rootFlags{Timeout: 2 * time.Second} + top := sonos.Topology{ + Groups: []sonos.Group{ + { + ID: "G1", + Coordinator: sonos.Member{ + Name: "Living Room", IP: "192.168.1.10", UUID: "RINCON_LR1400", + IsCoordinator: true, IsVisible: true, + }, + Members: []sonos.Member{ + {Name: "Living Room", IP: "192.168.1.10", UUID: "RINCON_LR1400", IsCoordinator: true, IsVisible: true}, + }, + }, + }, + } + store := &fakeSceneStore{} + origStore := newSceneStore + origTG := newSceneTopologyGetter + origClient := newSceneSpeakerClient + t.Cleanup(func() { + newSceneStore = origStore + newSceneTopologyGetter = origTG + newSceneSpeakerClient = origClient + }) + newSceneStore = func() (scenes.Store, error) { return store, nil } + newSceneTopologyGetter = func(ctx context.Context, timeout time.Duration) (sceneTopologyGetter, error) { + return &fakeSceneTopologyGetter{top: top}, nil + } + newSceneSpeakerClient = func(ip string, timeout time.Duration) sceneSpeakerClient { + return &fakeSceneSpeaker{ip: ip, volume: 0, mute: false} + } + + cmd := newSceneCmd(flags) + cmd.SetOut(newDiscardWriter()) + cmd.SetErr(newDiscardWriter()) + cmd.SetArgs([]string{"save", "Quiet"}) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + if err := cmd.ExecuteContext(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(store.put.Devices) != 1 || store.put.Devices[0].Volume != 0 || store.put.Devices[0].Mute { + t.Fatalf("device = %+v", store.put.Devices) + } +} + +func firstVolume(scene scenes.Scene) int { + if len(scene.Devices) == 0 { + return -1 + } + return scene.Devices[0].Volume +} + +func firstMute(scene scenes.Scene) bool { + if len(scene.Devices) == 0 { + return false + } + return scene.Devices[0].Mute +} + func TestSceneApplyUngroupsJoinsAndRestoresAudio(t *testing.T) { flags := &rootFlags{Timeout: 2 * time.Second} cmd := newSceneCmd(flags)