Skip to content
Closed
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
6 changes: 5 additions & 1 deletion cmd/marusya-agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.`)
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions configs/agent.conf.d.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
95 changes: 95 additions & 0 deletions internal/controller/capsulaipc/capsulaipc.go
Original file line number Diff line number Diff line change
@@ -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 <volume> <muted> ..."; 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
}
81 changes: 81 additions & 0 deletions internal/controller/capsulaipc/capsulaipc_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading