diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 04b4908..e90d7f9 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -7,19 +7,17 @@ # ghcr.io//marusya-server # ghcr.io//marusya-wake # -# Tags: "latest" and "master" from master, "1.2.3" / "1.2" from v1.2.3 tags, -# "sha-" from every push. Pull requests only build, nothing is pushed. -# Each platform is built on a native runner (no QEMU), then the digests are -# merged into one multi-arch manifest. -# -# The plugin catalog under registry/ has its own workflow (registry.yml). -# Pushes that only touch docs, YAML or the license build nothing. +# Tags: "latest" and "master" from master, "dev" and "stage" from stage, +# "1.2.3" / "1.2" from v1.2.3 tags, "sha-" from every push. +# Pull requests only build, nothing is pushed. Each platform is built on a +# native runner (no QEMU), then the digests are merged into one multi-arch +# manifest. name: docker on: push: - branches: [master] + branches: [master, stage] tags: ["v*"] paths-ignore: - "registry/**" @@ -164,6 +162,7 @@ jobs: type=semver,pattern={{major}}.{{minor}} type=sha type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=dev,enable=${{ github.ref == 'refs/heads/stage' }} - uses: docker/setup-buildx-action@v3 diff --git a/README.md b/README.md index 63773c7..0f5b564 100644 --- a/README.md +++ b/README.md @@ -199,9 +199,10 @@ docker compose -f deploy/server/docker-compose.yaml up -d --build docker compose -f deploy/server/docker-compose.yaml logs -f ``` -Готовые образы для amd64 и arm64 CI собирает из `master` и тегов `v*` +Готовые образы для amd64 и arm64 CI собирает из `master`, `stage` и тегов `v*` (`.github/workflows/docker.yml`): `ghcr.io/yawaflua/marusya-server` и -`ghcr.io/yawaflua/marusya-wake`. Чтобы не собирать локально: +`ghcr.io/yawaflua/marusya-wake`. Тег `latest` соответствует `master`, тег `dev` +соответствует `stage`. Чтобы не собирать локально: ```bash docker compose -f deploy/server/docker-compose.yaml pull @@ -841,6 +842,9 @@ log.Fatal(p.Run()) на колонке можно оставить пустым; - событие `state` сообщает, что делает Маруся: `idle`, `listening`, `thinking`, `speaking`, `error`, `announcing`, `disconnected`. Например, по `listening` можно приглушить свою музыку; +- событие `volume` приходит при любой смене громкости колонки (голосом, кнопками, из панели + или другим плагином): `data.level` от 0 до 100 и `data.muted`. По нему `airplay` двигает + ползунок громкости на телефоне; - функции плагина колонки попадают в список функций модели, когда колонка подключается (`hello.plugin_tools`). Модель вызывает их, сервер отправляет вызов на колонку, агент передаёт его плагину; diff --git a/cmd/marusya-agent/main.go b/cmd/marusya-agent/main.go index 0cce2aa..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 { @@ -384,6 +388,9 @@ func run() error { if *pluginsDir != "" { plugins = newSpeakerPlugins(*pluginsDir, pluginsdk.Device{ID: id, Language: *language, Location: *location, TimeZone: *timezone}, dispatcher, log) indicators = append(indicators, plugins) + if dispatcher.Volume != nil { + dispatcher.Volume = &controller.ObservedVolume{Volume: dispatcher.Volume, OnChange: plugins.Volume} + } } deps := agent.Deps{ Capture: alsa.NewCapture(alsaCfg, models.DeviceInputFormat), diff --git a/cmd/marusya-agent/plugins.go b/cmd/marusya-agent/plugins.go index 2390655..a99f4e7 100644 --- a/cmd/marusya-agent/plugins.go +++ b/cmd/marusya-agent/plugins.go @@ -116,6 +116,12 @@ func (p *speakerPlugins) Show(st indicator.State) { p.m.Event(pluginsdk.Event{Kind: pluginsdk.EventState, Device: p.dev.ID, Data: map[string]any{"state": st.String()}}) } +// Volume tells the plugins that the speaker's volume changed; it is the +// OnChange of a [controller.ObservedVolume]. +func (p *speakerPlugins) Volume(st models.VolumeState) { + p.m.Event(pluginsdk.Event{Kind: pluginsdk.EventVolume, Device: p.dev.ID, Data: map[string]any{"level": st.Level, "muted": st.Muted}}) +} + // Command implements [plugin.Handler]: actions run on this speaker. func (p *speakerPlugins) Command(ctx context.Context, plugin, _, action string, args map[string]any) (map[string]any, error) { res := p.dispatcher.Execute(ctx, &models.Command{ID: "plugin-" + plugin, Action: models.Action(action), Args: args}) 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/examples/plugins/airplay/main.go b/examples/plugins/airplay/main.go index 9940b74..28535fc 100644 --- a/examples/plugins/airplay/main.go +++ b/examples/plugins/airplay/main.go @@ -17,13 +17,18 @@ // // The speaker half reads the metadata pipe of shairport-sync (the AirPlay // receiver the panel installs on speakers), lights the LED ring while a -// phone plays and sends what plays to the server half. The server half +// phone is connected or plays, keeps the phone's volume slider and the +// speaker's volume together (unless capsule-mini-tweaker runs: then the +// bundle's ap2-bridge does it) and sends what plays to the server half. The server half // remembers it for every speaker and gives the model the airplay_status // function: "Маруся, что играет по эйрплею на кухне?". // -// The speaker needs metadata on in /etc/shairport-sync.conf: +// The speaker needs metadata on in /etc/shairport-sync.conf, and the +// receiver's own software volume off so the slider moves only the speaker's +// volume (the panel's installer sets both): // // metadata = { enabled = "yes"; include_cover_art = "no"; pipe_name = "/tmp/shairport-sync-metadata"; }; +// general = { ignore_volume_control = "yes"; ... }; // // The speaker and the server differ in architecture, so the same code is // built twice (see plugin.json): @@ -39,9 +44,13 @@ import ( "encoding/json" "encoding/xml" "errors" + "fmt" "io" "log" + "math" "os" + "os/exec" + "strconv" "strings" "sync" "time" @@ -51,12 +60,14 @@ import ( // Status is what the speaker half sends to the server half. type Status struct { - Playing bool `json:"playing"` - Paused bool `json:"paused,omitempty"` - Title string `json:"title,omitempty"` - Artist string `json:"artist,omitempty"` - Album string `json:"album,omitempty"` - Client string `json:"client,omitempty"` // the phone's name + // Connected means a phone has chosen the speaker, playing or not. + Connected bool `json:"connected,omitempty"` + Playing bool `json:"playing"` + Paused bool `json:"paused,omitempty"` + Title string `json:"title,omitempty"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + Client string `json:"client,omitempty"` // the phone's name } func main() { @@ -77,17 +88,114 @@ func speaker(p *pluginsdk.Plugin) { Pipe string `json:"metadata_pipe"` Color string `json:"color"` }{Pipe: "/tmp/shairport-sync-metadata", Color: "ffffff"} + vol := NewVolumes() + vol.Bridged = bridged p.OnInit(func(context.Context, pluginsdk.Init) error { if err := p.Config(&cfg); err != nil { return err } - go follow(p, cfg.Pipe, cfg.Color) + if bridged() { + p.Log("info", "%s found: the AirPlay bundle syncs the volume, the plugin only lights the ring", tweakerSocket) + } + go follow(p, cfg.Pipe, cfg.Color, vol) return nil }) + // Voice, keys and the panel move the phone's slider too. + p.OnEvent(func(ctx context.Context, e pluginsdk.Event) { + level, _ := e.Data["level"].(float64) + if muted, _ := e.Data["muted"].(bool); muted { + level = 0 + } + if !vol.speaker(int(level)) { + return + } + if err := setPhoneVolume(ctx, int(level)); err != nil { + p.Log("warn", "phone volume: %v", err) + } + }, pluginsdk.EventVolume) +} + +// tweakerSocket is the IPC of capsule-mini-tweaker. With it the AirPlay +// bundle installs its own two-way volume bridge (ap2-bridge), and two +// bridges would answer each other's echoes. +const tweakerSocket = "/run/capsule-controls.sock" + +// bridged reports whether the bundle's bridge syncs the volume already. +func bridged() bool { + fi, err := os.Stat(tweakerSocket) + return err == nil && fi.Mode()&os.ModeSocket != 0 +} + +// Volumes keeps the phone's slider and the speaker's volume together +// without sending a change back to where it came from. +type Volumes struct { + // Bridged, when it reports true, leaves the volume to someone else. + Bridged func() bool + + mu sync.Mutex + level int // the level both sides have; -1 when unknown + connected bool +} + +// NewVolumes returns Volumes before any phone connected. +func NewVolumes() *Volumes { return &Volumes{level: -1} } + +func (v *Volumes) off() bool { return v.Bridged != nil && v.Bridged() } + +// phone reports whether a level from the phone's slider is new to the +// speaker and has to be set. +func (v *Volumes) phone(level int) bool { + if v.off() { + return false + } + v.mu.Lock() + defer v.mu.Unlock() + if level == v.level { + return false + } + v.level = level + return true +} + +// speaker reports whether a new level of the speaker has to go to the +// phone: one is connected and the level did not come from it. +func (v *Volumes) speaker(level int) bool { + if v.off() { + return false + } + v.mu.Lock() + defer v.mu.Unlock() + if !v.connected || level == v.level { + return false + } + v.level = level + return true +} + +// setConnected follows the phone; the next phone starts afresh. +func (v *Volumes) setConnected(c bool) { + v.mu.Lock() + defer v.mu.Unlock() + if !c { + v.level = -1 + } + v.connected = c +} + +// setPhoneVolume moves the phone's slider through shairport-sync's D-Bus +// interface; dbus-send is on every speaker with the receiver. +func setPhoneVolume(ctx context.Context, level int) error { + out, err := exec.CommandContext(ctx, "dbus-send", "--system", "--print-reply", "--dest=org.gnome.ShairportSync", + "/org/gnome/ShairportSync", "org.gnome.ShairportSync.RemoteControl.SetAirplayVolume", + "double:"+strconv.FormatFloat(AirPlayDB(level), 'f', 2, 64)).CombinedOutput() + if err != nil { + return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) + } + return nil } // follow reads the pipe forever: it ends whenever shairport-sync restarts. -func follow(p *pluginsdk.Plugin, pipe, color string) { +func follow(p *pluginsdk.Plugin, pipe, color string, vol *Volumes) { ctx := context.Background() var st Status for { @@ -98,9 +206,18 @@ func follow(p *pluginsdk.Plugin, pipe, color string) { continue } err = Watch(f, func(it Item) { + if level, ok := Volume(it); ok { + if vol.phone(level) { + if _, err := p.Command(ctx, "", "volume.set", map[string]any{"level": level}); err != nil { + p.Log("warn", "volume: %v", err) + } + } + return + } if !st.apply(it) { return } + vol.setConnected(st.Connected) light(ctx, p, st, color) if err := p.Send(ctx, "", st); err != nil { p.Log("warn", "send: %v", err) @@ -115,7 +232,8 @@ func follow(p *pluginsdk.Plugin, pipe, color string) { } // light shows playback on the ring and the display: speaker actions run -// through host.command as if the server sent them. +// through host.command as if the server sent them. A phone that has chosen +// the speaker but does not play keeps a dim ring. func light(ctx context.Context, p *pluginsdk.Plugin, st Status, color string) { var err error switch { @@ -124,6 +242,8 @@ func light(ctx context.Context, p *pluginsdk.Plugin, st Status, color string) { if err == nil { _, err = p.Command(ctx, "", "display.show", map[string]any{"mode": "text", "text": "AIR", "seconds": 5}) } + case st.Connected: + _, err = p.Command(ctx, "", "ring.set", map[string]any{"scene": "solid", "color": color, "brightness": 8, "seconds": 12 * 3600}) default: _, err = p.Command(ctx, "", "ring.reset", nil) } @@ -169,10 +289,15 @@ func (st *Status) apply(it Item) bool { old := *st s := string(it.Data) switch it.Type + "/" + it.Code { + case "ssnc/abeg", "ssnc/conn": + st.Connected = true + case "ssnc/aend", "ssnc/disc": + *st = Status{} case "ssnc/pbeg", "ssnc/prsm": - st.Playing, st.Paused = true, false + st.Connected, st.Playing, st.Paused = true, true, false case "ssnc/pend": - *st = Status{} + // The phone stopped streaming but may still have the speaker chosen. + *st = Status{Connected: st.Connected, Client: st.Client} case "ssnc/pfls", "ssnc/paus": st.Paused = true case "ssnc/snam": @@ -187,6 +312,33 @@ func (st *Status) apply(it Item) bool { return *st != old } +// Volume returns the speaker's level, 0..100, for a "pvol" item. Its data +// is "airplay,volume,lowest,highest" in dB; the AirPlay volume runs from +// -30 (quietest) to 0, and -144 means muted. +func Volume(it Item) (int, bool) { + if it.Type != "ssnc" || it.Code != "pvol" { + return 0, false + } + first, _, _ := strings.Cut(string(it.Data), ",") + db, err := strconv.ParseFloat(strings.TrimSpace(first), 64) + if err != nil { + return 0, false + } + if db <= -144 { + return 0, true + } + return int(math.Round(max(0, min(100, (db+30)*100/30)))), true +} + +// AirPlayDB is the AirPlay volume in dB for the speaker's level: the +// inverse of [Volume]. +func AirPlayDB(level int) float64 { + if level <= 0 { + return -144 + } + return float64(min(level, 100))*0.3 - 30 +} + // server runs on the server: it collects the speakers' statuses. func server(p *pluginsdk.Plugin) { var mu sync.Mutex diff --git a/examples/plugins/airplay/main_test.go b/examples/plugins/airplay/main_test.go index 86209be..f37954d 100644 --- a/examples/plugins/airplay/main_test.go +++ b/examples/plugins/airplay/main_test.go @@ -17,6 +17,7 @@ package main import ( "errors" "io" + "strconv" "strings" "testing" ) @@ -53,7 +54,78 @@ func TestWatch(t *testing.T) { if got := seen[3]; !got.Playing || got.Paused || got.Title != "Кучер" || got.Artist != "Кино" || got.Client != "Маша" { t.Errorf("playing = %+v", got) } - if !seen[4].Paused || seen[5] != (Status{}) { + if !seen[4].Paused || seen[5] != (Status{Connected: true, Client: "Маша"}) { t.Errorf("pause and end = %+v %+v", seen[4], seen[5]) } + // The phone lets the speaker go. + if !st.apply(Item{Type: "ssnc", Code: "aend"}) || st != (Status{}) { + t.Errorf("after aend = %+v", st) + } + if !st.apply(Item{Type: "ssnc", Code: "abeg"}) || st != (Status{Connected: true}) { + t.Errorf("after abeg = %+v", st) + } +} + +func TestVolume(t *testing.T) { + for data, want := range map[string]int{ + "0.00,0.00,-96.30,0.00": 100, + "-15.00,-24.06,-96.30,0.00": 50, + "-30.00,-96.30,-96.30,0.00": 0, + "-144.00,-144.00,-96.30,0.00": 0, + "-22.5,-1,-96,0": 25, + } { + got, ok := Volume(Item{Type: "ssnc", Code: "pvol", Data: []byte(data)}) + if !ok || got != want { + t.Errorf("Volume(%q) = %d, %v; want %d", data, got, ok, want) + } + } + if _, ok := Volume(Item{Type: "ssnc", Code: "pvol", Data: []byte("x")}); ok { + t.Error("bad pvol accepted") + } + if _, ok := Volume(Item{Type: "core", Code: "minm", Data: []byte("1")}); ok { + t.Error("not pvol accepted") + } +} + +func TestAirPlayDBRoundTrip(t *testing.T) { + for level := 0; level <= 100; level++ { + data := strconv.FormatFloat(AirPlayDB(level), 'f', 2, 64) + ",0,-96,0" + if got, ok := Volume(Item{Type: "ssnc", Code: "pvol", Data: []byte(data)}); !ok || got != level { + t.Errorf("level %d came back as %d", level, got) + } + } +} + +func TestVolumesNoEcho(t *testing.T) { + v := NewVolumes() + if v.speaker(40) { + t.Error("speaker level sent with no phone") + } + v.setConnected(true) + // The phone drags its slider: the speaker follows and does not answer. + if !v.phone(30) || v.speaker(30) { + t.Error("phone level echoed back") + } + if v.phone(30) { + t.Error("same phone level set twice") + } + // A voice command: the phone follows, and its echo is not set again. + if !v.speaker(60) || v.phone(60) { + t.Error("speaker level not sent or echoed") + } + // A new phone sets its own level first. + v.setConnected(false) + v.setConnected(true) + if !v.phone(60) { + t.Error("new phone's level ignored") + } +} + +func TestVolumesBridged(t *testing.T) { + v := NewVolumes() + v.Bridged = func() bool { return true } + v.setConnected(true) + if v.phone(30) || v.speaker(60) { + t.Error("volume synced although the bundle's bridge does it") + } } diff --git a/examples/plugins/airplay/plugin.json b/examples/plugins/airplay/plugin.json index a68e899..667c63c 100644 --- a/examples/plugins/airplay/plugin.json +++ b/examples/plugins/airplay/plugin.json @@ -1,7 +1,7 @@ { "id": "airplay", "name": "AirPlay на кольце", - "version": "1.0.0", + "version": "1.2.0", "description": "Пример двустороннего плагина на Go: на колонке следит за shairport-sync, подсвечивает кольцо, пока телефон играет по AirPlay, и сообщает серверу, что играет; на сервере даёт модели функцию airplay_status.", "agent": {"command": ["./airplay-agent"]}, "server": {"command": ["./airplay-server"]}, 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") + } +} diff --git a/internal/server/install.go b/internal/server/install.go index 6d55830..17cfd09 100644 --- a/internal/server/install.go +++ b/internal/server/install.go @@ -136,9 +136,33 @@ trap 'rm -rf "$d"' EXIT cd "$d" tar xzf - cd capsula-mini-airplay2-* -sh install.sh --upgrade --name ` + shellQuote(name) + "\n" +sh install.sh --upgrade --name ` + shellQuote(name) + "\n" + airplayTune } +// airplayTune adjusts the receiver's config for the airplay plugin: the +// metadata pipe it reads (what plays, the phone's volume), and no software +// volume in shairport-sync, so the phone's slider moves the speaker's +// volume, which the display and the panel show, instead of attenuating the +// stream behind them. A config already tuned is left alone. +const airplayTune = `c="${INSTALL_ROOT:-}/etc/shairport-sync.conf" +[ -f "$c" ] || exit 0 +changed= +if ! grep -q 'ignore_volume_control' "$c"; then + awk '/^[ \t]*general[ \t]*=/ && !done {print; print "\tignore_volume_control = \"yes\";"; done=1; next} {print}' "$c" > "$d/conf" + cat "$d/conf" > "$c" + changed=1 +fi +if ! grep -q '^[ \t]*metadata[ \t]*=' "$c"; then + printf '\nmetadata = {\n\tenabled = "yes";\n\tinclude_cover_art = "no";\n\tpipe_name = "/tmp/shairport-sync-metadata";\n};\n' >> "$c" + changed=1 +fi +if [ -n "$changed" ]; then + sync + rc-service shairport-sync restart + echo "AirPlay: метаданные и громкость колонки включены" +fi +` + // spotifydScript installs spotifyd from apk and its service. Stdin carries // the init script, the default config and the D-Bus policy after __INIT__, // __CONF__ and __DBUS__ lines. An existing /etc/spotifyd.conf is kept. diff --git a/internal/server/install_test.go b/internal/server/install_test.go index 37f29a9..fb77d24 100644 --- a/internal/server/install_test.go +++ b/internal/server/install_test.go @@ -291,6 +291,11 @@ func TestInstallAirPlayAndSpotify(t *testing.T) { req := speakerRequest(t, addr, "pw") req.Name = "Кухня Маши" req.AirPlay, req.Spotify = true, true + // The bundle's stock config: no metadata, shairport-sync's own volume. + conf := filepath.Join(root, "etc/shairport-sync.conf") + if err := os.WriteFile(conf, []byte("general = {\n\tname = \"Кухня Маши\";\n};\n\nalsa = {\n\toutput_device = \"default\";\n};\n"), 0o644); err != nil { + t.Fatal(err) + } res, login, err := fakeInstaller(t, "tok").run(context.Background(), req) if err != nil { @@ -302,6 +307,14 @@ func TestInstallAirPlayAndSpotify(t *testing.T) { if !strings.Contains(res.Output, "INSTALL_PASS") { t.Errorf("output lacks the AirPlay installer's:\n%s", res.Output) } + got := readFile(t, conf) + if !strings.Contains(got, "general = {\n\tignore_volume_control = \"yes\";\n\tname") || + !strings.Contains(got, `pipe_name = "/tmp/shairport-sync-metadata";`) { + t.Errorf("shairport-sync.conf:\n%s", got) + } + if rc := readFile(t, filepath.Join(root, "rc.log")); !strings.Contains(rc, "shairport-sync restart") { + t.Errorf("shairport-sync not restarted:\n%s", rc) + } if _, err := os.Stat(filepath.Join(root, "apk.log")); err == nil { t.Error("apk ran although spotifyd is there") diff --git a/pkg/pluginsdk/protocol.go b/pkg/pluginsdk/protocol.go index 0260377..589e3fc 100644 --- a/pkg/pluginsdk/protocol.go +++ b/pkg/pluginsdk/protocol.go @@ -219,6 +219,10 @@ const ( // "idle", "listening", "thinking", "speaking", "error", // "disconnected" or "announcing". EventState = "state" + // EventVolume is a change of the speaker's volume by anyone: voice, + // keys, the panel or a plugin. Data["level"] is 0..100 and + // Data["muted"] is a bool. + EventVolume = "volume" ) // Event is sent with [MethodEvent]. diff --git a/sdk/README.md b/sdk/README.md index 5f400d7..5d1901c 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -92,8 +92,11 @@ Go и Python SDK умеют всё, что описано ниже. C++ SDK ум - `command`, `skill` — выполнено действие колонки или серверная функция; - `wake` — сработало ключевое слово. -На колонке есть одно событие, `state`: `data.state` принимает значения `idle`, `listening`, -`thinking`, `speaking`, `error`, `disconnected`, `announcing`. +События колонки: +- `state` — что делает Маруся: `data.state` принимает значения `idle`, `listening`, + `thinking`, `speaking`, `error`, `disconnected`, `announcing`; +- `volume` — громкость колонки изменилась (голосом, кнопками, из панели или плагином): + `data.level` от 0 до 100, `data.muted`. **`message`** (уведомление) — данные от другой половины плагина: `{"device":"kitchen","data":...}`.