diff --git a/cmd/marusya-agent/main.go b/cmd/marusya-agent/main.go index baf406f..5e7b6fc 100644 --- a/cmd/marusya-agent/main.go +++ b/cmd/marusya-agent/main.go @@ -47,6 +47,7 @@ import ( "github.com/yawaflua/marusya-gemini/internal/controller/airplay" "github.com/yawaflua/marusya-gemini/internal/controller/amixer" "github.com/yawaflua/marusya-gemini/internal/controller/bluez" + "github.com/yawaflua/marusya-gemini/internal/controller/capsulaipc" "github.com/yawaflua/marusya-gemini/internal/controller/mpv" "github.com/yawaflua/marusya-gemini/internal/controller/playerctl" "github.com/yawaflua/marusya-gemini/internal/controller/stream" @@ -92,6 +93,7 @@ var ( mixerCard = flag.String("mixer_card", config.Env("MARUSYA_MIXER_CARD", ""), "ALSA card for volume control.") mixerControl = flag.String("mixer_control", config.Env("MARUSYA_MIXER_CONTROL", "Master"), "ALSA simple mixer control for volume. Empty disables volume control.") + capsuleIPC = flag.Bool("capsule_ipc", config.EnvBool("MARUSYA_CAPSULE_IPC", false), "Control volume through the Capsula capsule-ipc tool (keeps the device panel, ALSA and AirPlay in sync).") bluetooth = flag.Bool("bluetooth", config.EnvBool("MARUSYA_BLUETOOTH", true), "Enable Bluetooth control via bluetoothctl.") btPairCommand = flag.String("bt_pair_command", config.Env("MARUSYA_BT_PAIR_COMMAND", "auto"), `Pairing tool, e.g. "bt-pair" or "bt-pair {timeout}". "auto" uses bt-pair if it is in PATH; empty uses the built-in bluetoothctl flow.`) @@ -226,7 +228,9 @@ func run() error { // Device controllers. Leave interfaces nil to disable an area. dispatcher := &controller.Dispatcher{} - if *mixerControl != "" { + if *capsuleIPC { + dispatcher.Volume = &capsulaipc.Volume{} + } else if *mixerControl != "" { dispatcher.Volume = &amixer.Volume{Card: *mixerCard, Control: *mixerControl} } if *bluetooth { diff --git a/configs/agent.conf.d.example b/configs/agent.conf.d.example index 40ca145..d37df39 100644 --- a/configs/agent.conf.d.example +++ b/configs/agent.conf.d.example @@ -16,6 +16,8 @@ MARUSYA_TIMEZONE= MARUSYA_CAPTURE_DEVICE=default MARUSYA_PLAYBACK_DEVICE=default MARUSYA_MIXER_CONTROL=Master +# Set to 1 on a VK Capsula Mini to control volume through capsule-ipc. +MARUSYA_CAPSULE_IPC= # Keyword spotter: reads 16 kHz mono S16LE on stdin, prints a line per hit. MARUSYA_WAKE_COMMAND= diff --git a/internal/controller/capsulaipc/capsulaipc.go b/internal/controller/capsulaipc/capsulaipc.go new file mode 100644 index 0000000..c58455a --- /dev/null +++ b/internal/controller/capsulaipc/capsulaipc.go @@ -0,0 +1,95 @@ +// Copyright 2026 The Marusya-Gemini Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package capsulaipc controls the VK Capsule Mini volume through capsule-ipc, +// the vendor IPC the device panel uses. Unlike amixer alone it keeps the +// panel, the hardware mixer and AirPlay volume in sync. +package capsulaipc + +import ( + "context" + "fmt" + "strconv" + "strings" + "sync" + + "github.com/yawaflua/marusya-gemini/internal/controller" + "github.com/yawaflua/marusya-gemini/internal/models" + "github.com/yawaflua/marusya-gemini/internal/syscmd" +) + +// Volume controls the speaker through capsule-ipc. +type Volume struct { + // Runner executes capsule-ipc. Defaults to [syscmd.Exec]. + Runner syscmd.Runner + + mu sync.Mutex +} + +var _ controller.Volume = (*Volume)(nil) + +func (v *Volume) run(ctx context.Context, command string) (string, error) { + r := v.Runner + if r == nil { + r = syscmd.Exec{} + } + return r.Run(ctx, "capsule-ipc", command) +} + +// Get implements [controller.Volume]. capsule-ipc GET replies with +// "OK ..."; the rest is the panel state. +func (v *Volume) Get(ctx context.Context) (models.VolumeState, error) { + out, err := v.run(ctx, "GET") + if err != nil { + return models.VolumeState{}, err + } + f := strings.Fields(out) + if len(f) < 3 || f[0] != "OK" { + return models.VolumeState{}, fmt.Errorf("capsule-ipc: unexpected reply %q", out) + } + level, err := strconv.Atoi(f[1]) + if err != nil { + return models.VolumeState{}, fmt.Errorf("capsule-ipc: volume %q: %w", f[1], err) + } + return models.VolumeState{Level: level, Muted: f[2] == "1"}, nil +} + +// Set implements [controller.Volume]. VOL also unmutes, like the panel. +func (v *Volume) Set(ctx context.Context, level int) error { + v.mu.Lock() + defer v.mu.Unlock() + if level < 0 { + level = 0 + } + if level > 100 { + level = 100 + } + _, err := v.run(ctx, "VOL "+strconv.Itoa(level)) + return err +} + +// SetMuted implements [controller.Volume]. capsule-ipc has only a toggle. +func (v *Volume) SetMuted(ctx context.Context, muted bool) error { + v.mu.Lock() + defer v.mu.Unlock() + st, err := v.Get(ctx) + if err != nil { + return err + } + if st.Muted == muted { + return nil + } + _, err = v.run(ctx, "MUTE") + return err +} diff --git a/internal/controller/capsulaipc/capsulaipc_test.go b/internal/controller/capsulaipc/capsulaipc_test.go new file mode 100644 index 0000000..6fc9b26 --- /dev/null +++ b/internal/controller/capsulaipc/capsulaipc_test.go @@ -0,0 +1,81 @@ +// Copyright 2026 The Marusya-Gemini Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package capsulaipc + +import ( + "context" + "reflect" + "testing" + + "github.com/yawaflua/marusya-gemini/internal/models" +) + +const reply = "OK 42 1 1 0 0 0 -1 -1 500 50 1500 6 3000 80557300 31.10.2026" + +type fakeRunner struct { + out string + calls [][]string +} + +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) (string, error) { + f.calls = append(f.calls, append([]string{name}, args...)) + return f.out, nil +} + +func TestGet(t *testing.T) { + v := &Volume{Runner: &fakeRunner{out: reply}} + got, err := v.Get(context.Background()) + if err != nil { + t.Fatal(err) + } + if want := (models.VolumeState{Level: 42, Muted: true}); got != want { + t.Errorf("Get() = %+v, want %+v", got, want) + } +} + +func TestSet(t *testing.T) { + r := &fakeRunner{} + v := &Volume{Runner: r} + if err := v.Set(context.Background(), 30); err != nil { + t.Fatal(err) + } + want := []string{"capsule-ipc", "VOL 30"} + if !reflect.DeepEqual(r.calls[0], want) { + t.Errorf("call = %v, want %v", r.calls[0], want) + } +} + +func TestSetMuted(t *testing.T) { + r := &fakeRunner{out: "OK 42 0 1 0 0 0 -1 -1 500 50 1500 6 3000 80557300 31.10.2026"} + v := &Volume{Runner: r} + if err := v.SetMuted(context.Background(), true); err != nil { + t.Fatal(err) + } + want := [][]string{{"capsule-ipc", "GET"}, {"capsule-ipc", "MUTE"}} + if !reflect.DeepEqual(r.calls, want) { + t.Errorf("calls = %v, want %v", r.calls, want) + } +} + +func TestSetMutedAlready(t *testing.T) { + r := &fakeRunner{out: "OK 42 1 1 0 0 0 -1 -1 500 50 1500 6 3000 80557300 31.10.2026"} + v := &Volume{Runner: r} + if err := v.SetMuted(context.Background(), true); err != nil { + t.Fatal(err) + } + if len(r.calls) != 1 { + t.Errorf("calls = %v, want only GET", r.calls) + } +}