Skip to content
Merged
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 @@ -51,6 +51,7 @@ import (
"github.com/yawaflua/marusya-gemini/internal/controller/mpv"
"github.com/yawaflua/marusya-gemini/internal/controller/playerctl"
"github.com/yawaflua/marusya-gemini/internal/controller/stream"
"github.com/yawaflua/marusya-gemini/internal/controller/volumecmd"
"github.com/yawaflua/marusya-gemini/internal/dbusapi"
"github.com/yawaflua/marusya-gemini/internal/display/seg7"
"github.com/yawaflua/marusya-gemini/internal/evdev"
Expand Down Expand Up @@ -93,6 +94,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.")
volumeCmd = flag.String("volume_cmd", config.Env("MARUSYA_VOLUME_CMD", ""), "External volume program, called as `<cmd> get`, `<cmd> set <0..100>` and `<cmd> mute on|off`. Takes precedence over mixer_control.")

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 @@ -228,7 +230,9 @@ func run() error {

// Device controllers. Leave interfaces nil to disable an area.
dispatcher := &controller.Dispatcher{}
if *mixerControl != "" {
if *volumeCmd != "" {
dispatcher.Volume = &volumecmd.Volume{Command: *volumeCmd}
} else if *mixerControl != "" {
dispatcher.Volume = &amixer.Volume{Card: *mixerCard, Control: *mixerControl}
}
if *bluetooth {
Expand Down
4 changes: 4 additions & 0 deletions configs/agent.conf.d.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ MARUSYA_TIMEZONE=
MARUSYA_CAPTURE_DEVICE=default
MARUSYA_PLAYBACK_DEVICE=default
MARUSYA_MIXER_CONTROL=Master
# External volume program, called as `<cmd> get`, `<cmd> set <0..100>`
# and `<cmd> mute on|off`. Takes precedence over MARUSYA_MIXER_CONTROL.
# See configs/volume-capsule-ipc.example.sh.
MARUSYA_VOLUME_CMD=

# Keyword spotter: reads 16 kHz mono S16LE on stdin, prints a line per hit.
MARUSYA_WAKE_COMMAND=
Expand Down
47 changes: 47 additions & 0 deletions configs/volume-capsule-ipc.example.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/bin/sh
# Example volume program for marusya-agent -volume_cmd
# (MARUSYA_VOLUME_CMD). It adapts capsule-ipc, the vendor IPC client that the
# VK Capsule Mini panel uses, to the generic contract:
#
# get -> "<level> <state>"
# set <0..100> -> set the level
# mute on|off -> mute or unmute
#
# Install as /usr/local/bin/marusya-volume, chmod 755, then set
# MARUSYA_VOLUME_CMD=/usr/local/bin/marusya-volume.
#
# Why not amixer: on this speaker the panel and AirPlay write the level
# through the same IPC, so changing only the ALSA control makes the panel
# and the phone slider disagree. capsule-ipc goes through that IPC.
set -eu

IPC=${CAPSULE_IPC:-/usr/local/bin/capsule-ipc}

case "${1:-}" in
get)
# capsule-ipc GET replies "OK <level> <muted> ...".
set -- $("$IPC" GET)
[ "${1:-}" = OK ] || { echo "$IPC GET: unexpected reply" >&2; exit 1; }
level=${2:?}
if [ "${3:-0}" = 1 ]; then state=muted; else state=unmuted; fi
printf '%s %s\n' "$level" "$state"
;;
set)
level=${2:?}
"$IPC" "VOL $level" >/dev/null
;;
mute)
want=${2:?}
set -- $("$IPC" GET)
muted=${3:-0}
case "$want:$muted" in
on:1 | off:0) exit 0 ;;
on:0 | off:1) "$IPC" MUTE >/dev/null ;;
*) echo "$0 mute: expected on or off, got $want" >&2; exit 2 ;;
esac
;;
*)
echo "usage: $0 get | set <0..100> | mute on|off" >&2
exit 2
;;
esac
128 changes: 128 additions & 0 deletions internal/controller/volumecmd/volumecmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// 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 volumecmd implements [controller.Volume] with an external program,
// so a device can use whichever tool owns its mixer without the agent knowing
// about it.
//
// The program is called with one of three subcommands:
//
// <cmd> get print "<level> <state>", e.g. "40 unmuted"
// <cmd> set <0..100> set the level in percent
// <cmd> mute on|off mute or unmute; setting the current state is a no-op
//
// The state word is optional and defaults to "unmuted"; a trailing "%" on the
// level is accepted. The program is not run through a shell, so arguments are
// exactly the ones listed above.
package volumecmd

import (
"context"
"errors"
"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 output level by running Command.
type Volume struct {
// Command is the program to run, e.g. "/usr/local/bin/volume".
Command string
// Runner executes the command. Defaults to [syscmd.Exec].
Runner syscmd.Runner

// mu serialises mute changes: a program may read the current state
// before toggling it.
mu sync.Mutex
}

var _ controller.Volume = (*Volume)(nil)

func (v *Volume) run(ctx context.Context, args ...string) (string, error) {
if v.Command == "" {
return "", errors.New("volumecmd: command is not configured")
}
r := v.Runner
if r == nil {
r = syscmd.Exec{}
}
return r.Run(ctx, v.Command, args...)
}

// Get implements [controller.Volume].
func (v *Volume) Get(ctx context.Context) (models.VolumeState, error) {
out, err := v.run(ctx, "get")
if err != nil {
return models.VolumeState{}, err
}
fields := strings.Fields(out)
if len(fields) == 0 {
return models.VolumeState{}, fmt.Errorf("volumecmd: %s get: empty output", v.Command)
}
level, err := strconv.Atoi(strings.TrimSuffix(fields[0], "%"))
if err != nil {
return models.VolumeState{}, fmt.Errorf("volumecmd: %s get: level %q is not a number", v.Command, fields[0])
}
if level < 0 {
level = 0
}
if level > 100 {
level = 100
}
state := models.VolumeState{Level: level}
if len(fields) > 1 {
switch strings.ToLower(fields[1]) {
case "muted":
state.Muted = true
case "unmuted":
default:
return models.VolumeState{}, fmt.Errorf("volumecmd: %s get: state %q is neither muted nor unmuted", v.Command, fields[1])
}
}
return state, nil
}

// Set implements [controller.Volume]. Levels outside 0..100 are clamped.
func (v *Volume) Set(ctx context.Context, level int) error {
level = clamp(level)
_, err := v.run(ctx, "set", strconv.Itoa(level))
return err
}

// SetMuted implements [controller.Volume].
func (v *Volume) SetMuted(ctx context.Context, muted bool) error {
v.mu.Lock()
defer v.mu.Unlock()
arg := "off"
if muted {
arg = "on"
}
_, err := v.run(ctx, "mute", arg)
return err
}

func clamp(level int) int {
if level < 0 {
return 0
}
if level > 100 {
return 100
}
return level
}
128 changes: 128 additions & 0 deletions internal/controller/volumecmd/volumecmd_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// 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 volumecmd

import (
"context"
"errors"
"testing"
)

type fakeRunner struct {
out string
err error
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, f.err
}

func TestGet(t *testing.T) {
cases := []struct {
name string
out string
level int
muted bool
}{
{"state", "42 unmuted", 42, false},
{"muted", "7 muted", 7, true},
{"no state", "55\n", 55, false},
{"percent", "80% unmuted", 80, false},
{"upper case", "12 MUTED", 12, true},
{"clamp high", "140 unmuted", 100, false},
{"clamp low", "-5 unmuted", 0, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := &fakeRunner{out: c.out}
v := &Volume{Command: "volume", Runner: r}
st, err := v.Get(context.Background())
if err != nil {
t.Fatal(err)
}
if st.Level != c.level || st.Muted != c.muted {
t.Fatalf("Get = %+v, want level %d muted %v", st, c.level, c.muted)
}
if len(r.calls) != 1 || r.calls[0][1] != "get" {
t.Fatalf("calls = %v, want [volume get]", r.calls)
}
})
}
}

func TestGetBadOutput(t *testing.T) {
for _, out := range []string{"", "loud", "40 louder"} {
r := &fakeRunner{out: out}
v := &Volume{Command: "volume", Runner: r}
if _, err := v.Get(context.Background()); err == nil {
t.Fatalf("Get(%q) must fail", out)
}
}
}

func TestSetClamps(t *testing.T) {
for _, c := range []struct {
in int
want string
}{{42, "42"}, {150, "100"}, {-3, "0"}, {0, "0"}, {100, "100"}} {
r := &fakeRunner{}
v := &Volume{Command: "volume", Runner: r}
if err := v.Set(context.Background(), c.in); err != nil {
t.Fatal(err)
}
got := r.calls[0]
if len(got) != 3 || got[1] != "set" || got[2] != c.want {
t.Fatalf("Set(%d) ran %v, want set %s", c.in, got, c.want)
}
}
}

func TestSetMuted(t *testing.T) {
for _, c := range []struct {
muted bool
want string
}{{true, "on"}, {false, "off"}} {
r := &fakeRunner{}
v := &Volume{Command: "volume", Runner: r}
if err := v.SetMuted(context.Background(), c.muted); err != nil {
t.Fatal(err)
}
got := r.calls[0]
if len(got) != 3 || got[1] != "mute" || got[2] != c.want {
t.Fatalf("SetMuted(%v) ran %v, want mute %s", c.muted, got, c.want)
}
}
}

func TestErrors(t *testing.T) {
boom := errors.New("boom")
v := &Volume{Command: "volume", Runner: &fakeRunner{err: boom}}
if _, err := v.Get(context.Background()); !errors.Is(err, boom) {
t.Fatalf("Get error = %v, want boom", err)
}
if err := v.Set(context.Background(), 10); !errors.Is(err, boom) {
t.Fatalf("Set error = %v, want boom", err)
}
if err := v.SetMuted(context.Background(), true); !errors.Is(err, boom) {
t.Fatalf("SetMuted error = %v, want boom", err)
}

empty := &Volume{Runner: &fakeRunner{out: "10 unmuted"}}
if _, err := empty.Get(context.Background()); err == nil {
t.Fatal("Get without a command must fail")
}
}
Loading