diff --git a/cmd/marusya-agent/main.go b/cmd/marusya-agent/main.go index 3e757b7..ea93939 100644 --- a/cmd/marusya-agent/main.go +++ b/cmd/marusya-agent/main.go @@ -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" @@ -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 ` get`, ` set <0..100>` and ` 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.`) @@ -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 { diff --git a/configs/agent.conf.d.example b/configs/agent.conf.d.example index 40ca145..412d79a 100644 --- a/configs/agent.conf.d.example +++ b/configs/agent.conf.d.example @@ -16,6 +16,10 @@ MARUSYA_TIMEZONE= MARUSYA_CAPTURE_DEVICE=default MARUSYA_PLAYBACK_DEVICE=default MARUSYA_MIXER_CONTROL=Master +# External volume program, called as ` get`, ` set <0..100>` +# and ` 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= diff --git a/configs/volume-capsule-ipc.example.sh b/configs/volume-capsule-ipc.example.sh new file mode 100644 index 0000000..fc4de58 --- /dev/null +++ b/configs/volume-capsule-ipc.example.sh @@ -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 -> " " +# 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 ...". + 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 diff --git a/internal/controller/volumecmd/volumecmd.go b/internal/controller/volumecmd/volumecmd.go new file mode 100644 index 0000000..231bce4 --- /dev/null +++ b/internal/controller/volumecmd/volumecmd.go @@ -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: +// +// get print " ", e.g. "40 unmuted" +// set <0..100> set the level in percent +// 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 +} diff --git a/internal/controller/volumecmd/volumecmd_test.go b/internal/controller/volumecmd/volumecmd_test.go new file mode 100644 index 0000000..0aa67b7 --- /dev/null +++ b/internal/controller/volumecmd/volumecmd_test.go @@ -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") + } +}