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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | поведение диалога |

### Ключевое слово
Expand Down
6 changes: 6 additions & 0 deletions cmd/marusya-agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.")
)
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions configs/agent.conf.d.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<cmd> get`, `<cmd> set <0..100>`
# and `<cmd> mute on|off`. Takes precedence over MARUSYA_MIXER_CONTROL.
# See configs/volume-capsule-ipc.example.sh.
Expand Down
87 changes: 87 additions & 0 deletions internal/indicator/statecmd/statecmd.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
121 changes: 121 additions & 0 deletions internal/indicator/statecmd/statecmd_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading