From e1a5431c231a5de37acdef3cf4486b1e31008689 Mon Sep 17 00:00:00 2001 From: ryysel Date: Sun, 1 Nov 2026 05:37:41 +0300 Subject: [PATCH] agent: run a command on assistant state changes --- README.md | 1 + cmd/marusya-agent/main.go | 6 + configs/agent.conf.d.example | 3 + internal/indicator/statecmd/statecmd.go | 87 +++++++++++++ internal/indicator/statecmd/statecmd_test.go | 121 +++++++++++++++++++ 5 files changed, 218 insertions(+) create mode 100644 internal/indicator/statecmd/statecmd.go create mode 100644 internal/indicator/statecmd/statecmd_test.go diff --git a/README.md b/README.md index 6d47f3e..b7b2f69 100644 --- a/README.md +++ b/README.md @@ -551,6 +551,7 @@ dbus-monitor --system "sender='ru.marusya.Agent'" | `-display_rotate` | перевернуть мордашку на экране из `clock-led1…30` (сборки на стоковом ядре) | | `-ring_dev` | spidev-узел кольца, по умолчанию `/dev/spidev0.0`; кадры пишутся прямо в него, 30 к/с | | `-ring_demo` | показать все анимации кольца по очереди и выйти (остановите сервис перед запуском) | +| `-state_command` | команда, которой передаётся состояние ассистента последним аргументом: `idle`, `listening`, `thinking`, `speaking`, `error`, `disconnected`, `announcing`. Для колонок, где кольцом или экраном владеет другой процесс: агент сообщает состояние, процесс рисует. Повторы пропускаются, команда не блокирует | | `-follow_up`, `-duck_media`, `-earcons` | поведение диалога | ### Ключевое слово diff --git a/cmd/marusya-agent/main.go b/cmd/marusya-agent/main.go index ea93939..e58e20f 100644 --- a/cmd/marusya-agent/main.go +++ b/cmd/marusya-agent/main.go @@ -58,6 +58,7 @@ import ( "github.com/yawaflua/marusya-gemini/internal/indicator" "github.com/yawaflua/marusya-gemini/internal/indicator/face" "github.com/yawaflua/marusya-gemini/internal/indicator/ringled" + "github.com/yawaflua/marusya-gemini/internal/indicator/statecmd" "github.com/yawaflua/marusya-gemini/internal/logging" "github.com/yawaflua/marusya-gemini/internal/media" "github.com/yawaflua/marusya-gemini/internal/models" @@ -131,6 +132,7 @@ var ( ringDev = flag.String("ring_dev", config.Env("MARUSYA_RING_DEV", ringled.DefaultDevice), "spidev node of the LED ring.") ringledBrightness = flag.Int("ringled_brightness", 30, "LED ring brightness, per cent.") ringDemo = flag.Bool("ring_demo", false, "Play every LED ring animation once and exit.") + stateCommand = flag.String("state_command", config.Env("MARUSYA_STATE_COMMAND", ""), "Run this command with the assistant state (idle, listening, thinking, speaking, error, disconnected, announcing) on every change, e.g. to drive a ring owned by another process.") logLevel = flag.String("log_level", config.Env("MARUSYA_LOG_LEVEL", "info"), "Log level: debug, info, warn, error.") ) @@ -307,6 +309,10 @@ func run() error { dispatcher.Sensors = surf } indicators = append(indicators, surf) + if *stateCommand != "" { + indicators = append(indicators, &statecmd.Indicator{Command: strings.Fields(*stateCommand), Logger: log}) + log.Info("state command enabled", "cmd", *stateCommand) + } go surf.Run(ctx) if *dbusService { go serveDBus(ctx, surf, log) diff --git a/configs/agent.conf.d.example b/configs/agent.conf.d.example index 412d79a..550c686 100644 --- a/configs/agent.conf.d.example +++ b/configs/agent.conf.d.example @@ -16,6 +16,9 @@ MARUSYA_TIMEZONE= MARUSYA_CAPTURE_DEVICE=default MARUSYA_PLAYBACK_DEVICE=default MARUSYA_MIXER_CONTROL=Master +# Run this command with the assistant state on every change, e.g. to +# drive an LED ring owned by another process (see indicator/statecmd). +MARUSYA_STATE_COMMAND= # 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. diff --git a/internal/indicator/statecmd/statecmd.go b/internal/indicator/statecmd/statecmd.go new file mode 100644 index 0000000..1fe614c --- /dev/null +++ b/internal/indicator/statecmd/statecmd.go @@ -0,0 +1,87 @@ +// 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 statecmd shows the assistant state by running an external command. +// It is for devices whose ring or display belongs to another process: the +// agent reports the state, that process draws it. +// +// The state is appended as the last argument: idle, listening, thinking, +// speaking, error, disconnected or announcing. Repeated states are skipped. +package statecmd + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/yawaflua/marusya-gemini/internal/indicator" + "github.com/yawaflua/marusya-gemini/internal/syscmd" +) + +// timeout bounds one run: a slow or hung command must not delay the next +// state for long. +const timeout = 3 * time.Second + +// Indicator runs a command on every state change. +type Indicator struct { + // Command is the program and its fixed arguments; the state is appended. + Command []string + // Runner executes the command. Defaults to [syscmd.Exec]. + Runner syscmd.Runner + // Logger reports failed runs. Nil discards them. + Logger *slog.Logger + + // mu guards the last shown state. + mu sync.Mutex + last indicator.State + seen bool + // runMu keeps runs in order, so a quick listening -> thinking sequence + // cannot arrive reversed. + runMu sync.Mutex +} + +var _ indicator.Indicator = (*Indicator)(nil) + +// Show implements [indicator.Indicator]. It returns at once and runs the +// command in the background. +func (i *Indicator) Show(s indicator.State) { + if len(i.Command) == 0 { + return + } + i.mu.Lock() + if i.seen && i.last == s { + i.mu.Unlock() + return + } + i.last, i.seen = s, true + i.mu.Unlock() + go i.run(s) +} + +func (i *Indicator) run(s indicator.State) { + i.runMu.Lock() + defer i.runMu.Unlock() + + r := i.Runner + if r == nil { + r = syscmd.Exec{} + } + args := append(append([]string{}, i.Command[1:]...), s.String()) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + if _, err := r.Run(ctx, i.Command[0], args...); err != nil && i.Logger != nil { + i.Logger.Warn("state command failed", "state", s.String(), "err", err) + } +} diff --git a/internal/indicator/statecmd/statecmd_test.go b/internal/indicator/statecmd/statecmd_test.go new file mode 100644 index 0000000..46c64fd --- /dev/null +++ b/internal/indicator/statecmd/statecmd_test.go @@ -0,0 +1,121 @@ +// 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 statecmd + +import ( + "context" + "testing" + "time" + + "github.com/yawaflua/marusya-gemini/internal/indicator" +) + +type call struct { + name string + args []string +} + +type fakeRunner struct { + calls chan call +} + +func newFake() *fakeRunner { return &fakeRunner{calls: make(chan call, 8)} } + +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) (string, error) { + f.calls <- call{name: name, args: args} + return "", nil +} + +func waitCall(t *testing.T, f *fakeRunner) call { + t.Helper() + select { + case c := <-f.calls: + return c + case <-time.After(2 * time.Second): + t.Fatal("no command run") + return call{} + } +} + +func noCall(t *testing.T, f *fakeRunner) { + t.Helper() + select { + case c := <-f.calls: + t.Fatalf("unexpected run: %+v", c) + case <-time.After(100 * time.Millisecond): + } +} + +func TestShowRunsCommandWithState(t *testing.T) { + r := newFake() + i := &Indicator{Command: []string{"ring", "--quiet"}, Runner: r} + + i.Show(indicator.Listening) + if c := waitCall(t, r); c.name != "ring" || len(c.args) != 2 || c.args[0] != "--quiet" || c.args[1] != "listening" { + t.Fatalf("ran %+v, want ring --quiet listening", c) + } + + i.Show(indicator.Thinking) + if c := waitCall(t, r); c.args[1] != "thinking" { + t.Fatalf("ran %+v, want thinking", c) + } + + i.Show(indicator.Idle) + if c := waitCall(t, r); c.args[1] != "idle" { + t.Fatalf("ran %+v, want idle", c) + } +} + +func TestShowSkipsRepeatedState(t *testing.T) { + r := newFake() + i := &Indicator{Command: []string{"ring"}, Runner: r} + + i.Show(indicator.Listening) + waitCall(t, r) + i.Show(indicator.Listening) + noCall(t, r) + + i.Show(indicator.Thinking) + if c := waitCall(t, r); c.args[0] != "thinking" { + t.Fatalf("ran %+v, want thinking", c) + } +} + +func TestShowWithoutCommand(t *testing.T) { + r := newFake() + i := &Indicator{Runner: r} + i.Show(indicator.Listening) + noCall(t, r) +} + +func TestShowDoesNotBlock(t *testing.T) { + r := newFake() + i := &Indicator{Command: []string{"ring"}, Runner: r} + block := make(chan struct{}) + defer close(block) + go func() { + <-block + }() + done := make(chan struct{}) + go func() { + i.Show(indicator.Speaking) + close(done) + }() + select { + case <-done: + case <-time.After(100 * time.Millisecond): + t.Fatal("Show blocked") + } +}