Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/scenes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 8 additions & 2 deletions internal/cli/scene.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
150 changes: 148 additions & 2 deletions internal/cli/scene_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ type fakeSceneSpeaker struct {

setMuteCalls int
setMuteValue bool

volumeErr error
muteErr error
}

func (f *fakeSceneSpeaker) LeaveGroup(ctx context.Context) error {
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down