diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e90d7f9..6bad055 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,11 +1,15 @@ # Copyright 2026 The Marusya-Gemini Authors. # SPDX-License-Identifier: Apache-2.0 -# Builds the marusya-server and marusya-wake images for linux/amd64 and -# linux/arm64 and pushes them to GitHub Container Registry: +# Builds the opencapsule-server and opencapsule-wake images for linux/amd64 +# and linux/arm64 and pushes them to GitHub Container Registry: # -# ghcr.io//marusya-server -# ghcr.io//marusya-wake +# ghcr.io//opencapsule-server +# ghcr.io//opencapsule-wake +# +# The same manifests are also tagged under the obsolete names marusya-server +# and marusya-wake, annotated as deprecated, so existing compose files keep +# pulling updates until they switch to the new names. # # 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. @@ -61,10 +65,10 @@ jobs: fail-fast: false matrix: image: - - name: marusya-server + - name: opencapsule-server context: . file: deploy/server/Dockerfile - - name: marusya-wake + - name: opencapsule-wake context: deploy/wake file: deploy/wake/Dockerfile platform: @@ -137,7 +141,11 @@ jobs: needs: build strategy: matrix: - image: [marusya-server, marusya-wake] + include: + - image: opencapsule-server + legacy: marusya-server + - image: opencapsule-wake + legacy: marusya-wake runs-on: ubuntu-24.04 permissions: contents: read @@ -150,7 +158,9 @@ jobs: merge-multiple: true - id: vars - run: echo "image=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/${{ matrix.image }}" >> "$GITHUB_OUTPUT" + run: | + echo "image=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/${{ matrix.image }}" >> "$GITHUB_OUTPUT" + echo "legacy=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/${{ matrix.legacy }}" >> "$GITHUB_OUTPUT" - id: meta uses: docker/metadata-action@v5 @@ -179,5 +189,17 @@ jobs: $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ $(printf '${{ steps.vars.outputs.image }}@sha256:%s ' *) + # Obsolete name, kept for compatibility: same tags, same manifests. + - name: Tag the obsolete name + env: + IMAGE: ${{ steps.vars.outputs.image }} + LEGACY: ${{ steps.vars.outputs.legacy }} + run: | + tags=$(jq -cr --arg i "$IMAGE" --arg l "$LEGACY" \ + '.tags | map("-t " + ($l + ltrimstr($i))) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") + docker buildx imagetools create $tags \ + --annotation "index:org.opencontainers.image.description=OBSOLETE: use $IMAGE instead" \ + "$IMAGE:${{ steps.meta.outputs.version }}" + - name: Inspect image run: docker buildx imagetools inspect ${{ steps.vars.outputs.image }}:${{ steps.meta.outputs.version }} diff --git a/AUTHORS b/AUTHORS index f897b13..dd28300 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,5 +1,5 @@ -# The Marusya-Gemini Authors: the copyright holders referred to as -# "The Marusya-Gemini Authors" in the source file headers. +# The OpenCapsula Authors: the copyright holders of the project (the source +# file headers still say "The Marusya-Gemini Authors", its former name). # # Contributors add themselves here in their first pull request, one line # per person: Name (GitHub handle). diff --git a/NOTICE b/NOTICE index 00cde79..4084aef 100644 --- a/NOTICE +++ b/NOTICE @@ -1,5 +1,12 @@ -Marusya × Gemini -Copyright 2026 The Marusya-Gemini Authors: +OpenCapsula (formerly Marusya × Gemini) +Copyright 2026 The OpenCapsula Authors: Dmitri 'yawaflua' Shimanski and project contributors (see AUTHORS). Licensed under the Apache License, Version 2.0 (see LICENSE). + +OpenCapsula is developed by independent enthusiasts. It is not affiliated +with, endorsed or supported by VK (MKPAO "VK") or its employees. "Маруся", +"VK Капсула" and related names are trademarks of their respective owners and +are used only to describe compatibility. + +Source: https://github.com/yawaflua/OpenCapsule diff --git a/README.md b/README.md index 2d6d5b3..ceb5218 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Marusya × Gemini +# OpenCapsula -Голосовой ассистент для умной колонки «Маруся» на базе **Gemini Live API**. +OpenCapsula — открытый голосовой ассистент для умной колонки «Маруся» (VK Капсула) на базе **Gemini Live API**. Проект состоит из двух частей на Go: | Бинарник | Где работает | Что делает | @@ -19,8 +19,9 @@ ## Быстрый старт Нужен сервер с Docker (amd64 или arm64) в одной сети с колонкой и root по SSH на колонке. -Готовые образы `ghcr.io/yawaflua/marusya-server` и `ghcr.io/yawaflua/marusya-wake` публичные, -собирать ничего не надо. +Готовые образы `ghcr.io/yawaflua/opencapsule-server` и `ghcr.io/yawaflua/opencapsule-wake` публичные, +собирать ничего не надо. Старые имена `marusya-server` и `marusya-wake` устарели: под ними +публикуются те же образы для совместимости, но лучше перейти на новые. **1. Docker и папка для сервера** @@ -28,7 +29,7 @@ curl -fsSL https://get.docker.com | sh mkdir -p ~/marusya/plugins && cd ~/marusya sudo chown 65532 plugins # плагины хранят тут своё состояние -curl -fsSLO https://raw.githubusercontent.com/yawaflua/Gemini-for-Capsula/master/docker-compose.yaml +curl -fsSLO https://raw.githubusercontent.com/yawaflua/OpenCapsule/master/docker-compose.yaml ``` **2. Ключи в `.env`** (рядом с `docker-compose.yaml`) @@ -200,8 +201,9 @@ docker compose -f deploy/server/docker-compose.yaml logs -f ``` Готовые образы для amd64 и arm64 CI собирает из `master`, `stage` и тегов `v*` -(`.github/workflows/docker.yml`): `ghcr.io/yawaflua/marusya-server` и -`ghcr.io/yawaflua/marusya-wake`. Тег `latest` соответствует `master`, тег `dev` +(`.github/workflows/docker.yml`): `ghcr.io/yawaflua/opencapsule-server` и +`ghcr.io/yawaflua/opencapsule-wake`. Те же образы пока выходят и под старыми именами +`marusya-server` и `marusya-wake`, но они помечены устаревшими (obsolete). Тег `latest` соответствует `master`, тег `dev` соответствует `stage`. Чтобы не собирать локально: ```bash @@ -580,6 +582,10 @@ cd deploy/server && docker compose --profile wake up -d --build - Можно задать до 10 слов через запятую. Панель предупредит, если слово не поддерживается. - Кнопка и `-wake_command` на колонке продолжают работать. Без `MARUSYA_WAKE_URL`, а также со старым агентом звук в режиме ожидания никуда не отправляется. +- Когда к серверу не подключена ни одна колонка, сервис засыпает: через `WAKE_SLEEP_AFTER` + секунд (по умолчанию 60) он выгружает модели Vosk и Whisper из памяти и загружает их снова, + когда колонка вернётся. Первое срабатывание после сна приходит на пару секунд позже. + `WAKE_SLEEP_AFTER=0` держит модели загруженными всегда. > Важно: при серверном слове микрофон слушает постоянно, и звук уходит на ваш сервер > в локальной сети. Дальше сервера он не идёт: в Gemini попадает только запрос после @@ -610,7 +616,10 @@ curl -s http://192.168.31.80:8080/v1/ask -H "Authorization: Bearer $MARUSYA_AUTH 4. С `listen` колонка 10 секунд ждёт ответа. Ответ распознаётся и возвращается в JSON. Модели он не передаётся, Маруся сама ничего не отвечает. 5. Если нажать кнопку во время чтения, колонка перестанет читать и сразу начнёт слушать. -Если никто не ответил, придёт `"answered": false`. Поля запроса: +Если никто не ответил, придёт `"answered": false`. Если к серверу не подключена ни одна +колонка, сервер спит: запрос сразу пропускается с `"skipped": true` (код 200), ничего не +озвучивается и не ждёт. Хук `marusya-ask` в этом случае молча завершается, и Claude Code +работает как без него. Поля запроса: - `text` — что сказать, до 1000 символов; - `listen` — ждать ли ответа; - `device` — ID колонки (по умолчанию первая); @@ -1052,5 +1061,12 @@ WebSocket `GET /v1/stream`, авторизация `Authorization: Bearer str: _vosk_model = None _whisper_model = None _whisper_error = None +_generation = 0 # bumped by unload_models, so a late Whisper load is dropped _lock = threading.Lock() @@ -95,6 +104,7 @@ def whisper_model(): if getattr(whisper_model, "loading", False): return None whisper_model.loading = True + generation = _generation def load(): global _whisper_model, _whisper_error @@ -106,17 +116,63 @@ def load(): m = WhisperModel(WHISPER_MODEL, device=WHISPER_DEVICE, compute_type=WHISPER_COMPUTE, cpu_threads=WHISPER_THREADS) with _lock: - _whisper_model = m + if generation == _generation: + _whisper_model = m log.info("Whisper model ready") except Exception as e: # noqa: BLE001 - reported to the server log.exception("loading Whisper failed") with _lock: - _whisper_error = e + if generation == _generation: + _whisper_error = e + finally: + with _lock: + if generation == _generation: + whisper_model.loading = False threading.Thread(target=load, daemon=True).start() return None +def unload_models(): + """Drops the models; the next connection loads them again.""" + global _vosk_model, _whisper_model, _whisper_error, _generation + with _lock: + loaded = _vosk_model is not None or _whisper_model is not None + _vosk_model = _whisper_model = _whisper_error = None + _generation += 1 + whisper_model.loading = False + gc.collect() + return loaded + + +class Sleeper: + """Unloads the models a while after the last connection closes.""" + + def __init__(self, delay): + self.delay = delay + self.clients = 0 + self.timer = None + + def connected(self): + self.clients += 1 + if self.timer is not None: + self.timer.cancel() + self.timer = None + + def disconnected(self): + self.clients -= 1 + if self.clients == 0 and self.delay > 0: + self.timer = asyncio.get_running_loop().call_later(self.delay, self.sleep) + + def sleep(self): + self.timer = None + if self.clients == 0 and unload_models(): + log.info("no speakers: models unloaded, sleeping until one connects") + + +sleeper = Sleeper(SLEEP_AFTER) + + # ---------- engines ---------- @@ -417,6 +473,14 @@ def feed_all(spotters, pcm, now, emit): async def handler(ws): + sleeper.connected() + try: + await serve_speaker(ws) + finally: + sleeper.disconnected() + + +async def serve_speaker(ws): loop = asyncio.get_running_loop() peer = ws.remote_address spotters = [] @@ -468,7 +532,9 @@ def send(): async def main(): host, _, port = os.environ.get("WAKE_LISTEN", "0.0.0.0:2700").rpartition(":") - vosk_model() # fail fast on a broken image + if not os.path.isdir(VOSK_MODEL): # fail fast on a broken image + raise SystemExit(f"no Vosk model at {VOSK_MODEL}") + import vosk # noqa: F401 - the model itself is loaded on the first connection async with serve(handler, host or "0.0.0.0", int(port), max_size=1 << 20, ping_interval=20, ping_timeout=60): log.info("listening on %s:%s", host, port) diff --git a/docker-compose.yaml b/docker-compose.yaml index 15c3a6a..75b6a12 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,6 +1,6 @@ services: marusya-server: - image: ghcr.io/yawaflua/marusya-server:latest + image: ghcr.io/yawaflua/opencapsule-server:latest container_name: marusya-server restart: unless-stopped ports: @@ -39,7 +39,7 @@ services: max-file: "3" marusya-wake: - image: ghcr.io/yawaflua/marusya-wake:latest + image: ghcr.io/yawaflua/opencapsule-wake:latest container_name: marusya-wake restart: unless-stopped environment: diff --git a/internal/indicator/face/frames.go b/internal/indicator/face/frames.go index 382bef7..d8315e4 100644 --- a/internal/indicator/face/frames.go +++ b/internal/indicator/face/frames.go @@ -37,6 +37,8 @@ const ( eyeRight = seg7.B | seg7.C // pupil right eyeAngryL = seg7.A | seg7.B | seg7.G eyeAngryR = seg7.A | seg7.F | seg7.G + eyeSlumpL = seg7.D | seg7.C // _| + eyeSlumpR = seg7.E | seg7.D // |_ letterZ = seg7.A | seg7.B | seg7.G | seg7.E | seg7.D ) @@ -67,6 +69,7 @@ func tired(ms int) Frame { return fr(wide(eyeLine, eyeLine), true, ms) } func winking(ms int) Frame { return fr(wide(eyeOpen, eyeClosed), true, ms) } func down(ms int) Frame { return fr(wide(eyeLow, eyeLow), true, ms) } func angry(ms int) Frame { return fr(wide(eyeAngryL, eyeAngryR), true, ms) } +func slumped(ms int) Frame { return fr(wide(eyeSlumpL, eyeSlumpR), true, ms) } func lookLeft(ms int) Frame { return fr(toLeft(eyeLeft, eyeLeft), true, ms) } func lookRight(ms int) Frame { return fr(toRight(eyeRight, eyeRight), true, ms) } @@ -98,8 +101,8 @@ var ( } // loopSpeaking: happy eyes, the nose "talks". loopSpeaking = []Frame{happy(260), fr(wide(eyeHappy, eyeHappy), false, 160), happy(320), fr(narrow(eyeHappy, eyeHappy), true, 140)} - // loopDisconnected: sad face that sometimes nods off. - loopDisconnected = []Frame{sad(2500), tired(300), asleep(500), tired(200)} + // loopDisconnected: slumped "_| . |_" face that sometimes nods off. + loopDisconnected = []Frame{slumped(2500), tired(300), asleep(500), tired(200)} // loopSleeping: "- . -" whose eyes slowly sink and rise as if breathing. loopSleeping = []Frame{ fr(wide(eyeLine, eyeLine), true, 2600), diff --git a/internal/server/ask.go b/internal/server/ask.go index 4a0653e..b8b31e1 100644 --- a/internal/server/ask.go +++ b/internal/server/ask.go @@ -36,6 +36,9 @@ import ( // {"text": "Клод спрашивает: …", "listen": true, "device": "", "timeout": 120, "source": "claude"} // → {"device": "vk-mini-a113x", "answered": true, "answer": "да, продолжай"} // +// While no speaker is connected the server sleeps (see [Server.Asleep]) and +// answers at once with {"skipped": true}, so hooks do not hang. +// // The message is synthesized, played once the speaker is free (with a chime // and an orange ring) and, with listen, the next utterance is transcribed // and returned instead of going to the model. @@ -58,6 +61,7 @@ var ( errAskTimeout = errors.New("колонка была занята всё это время") errNoAnswer = errors.New("no answer") errAskReplaced = errors.New("колонка отключилась") + errAsleep = errors.New("нет подключённых колонок: запрос пропущен") ) // pendingAsk is an announcement being delivered to a device. @@ -91,7 +95,10 @@ type AskResponse struct { Device string `json:"device"` Answered bool `json:"answered"` Answer string `json:"answer,omitempty"` - Error string `json:"error,omitempty"` + // Skipped means no speaker is connected, so nothing was said; callers + // such as hooks should carry on as if the ask API were not there. + Skipped bool `json:"skipped,omitempty"` + Error string `json:"error,omitempty"` } func (s *Server) handleAsk(w http.ResponseWriter, r *http.Request) { @@ -113,6 +120,10 @@ func (s *Server) handleAsk(w http.ResponseWriter, r *http.Request) { writeAsk(w, http.StatusBadRequest, AskResponse{Error: "text is too long"}) return } + if s.Asleep() { + writeAsk(w, http.StatusOK, AskResponse{Device: req.Device, Skipped: true, Error: errAsleep.Error()}) + return + } wait := defaultAskWait if req.Timeout > 0 { wait = min(time.Duration(req.Timeout)*time.Second, maxAskWait) diff --git a/internal/server/ask_test.go b/internal/server/ask_test.go index 23b908a..d8f1fac 100644 --- a/internal/server/ask_test.go +++ b/internal/server/ask_test.go @@ -177,8 +177,8 @@ func TestAskRejected(t *testing.T) { e := newAskEnv(t) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - if r := <-e.post("tok", AskRequest{Text: "x"}); r[0] != http.StatusServiceUnavailable { - t.Errorf("no speaker = %v", r[0]) + if r := <-e.post("tok", AskRequest{Text: "x", Listen: true}); r[0] != http.StatusOK || !r[1].(AskResponse).Skipped { + t.Errorf("no speaker = %v %+v, want skipped", r[0], r[1]) } e.dial(ctx) // an old agent without announcements for range 100 { @@ -186,7 +186,7 @@ func TestAskRejected(t *testing.T) { t.Fatalf("wrong token = %v", r[0]) } r := <-e.post("tok", AskRequest{Text: "x"}) - if r[0] == http.StatusServiceUnavailable { + if r[1].(AskResponse).Skipped { time.Sleep(10 * time.Millisecond) // not registered yet continue } @@ -198,4 +198,31 @@ func TestAskRejected(t *testing.T) { t.Fatal("device did not register") } +func TestAsleepWithoutSpeakers(t *testing.T) { + s := New(Config{Provider: &textProvider{texts: make(chan string, 1)}, AuthToken: "tok", + Logger: slog.New(slog.DiscardHandler)}) + ts := httptest.NewServer(s) + defer ts.Close() + if !s.Asleep() { + t.Fatal("awake without speakers") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + conn, m, err := dialDevice(t, ctx, ts.URL, "tok", &models.Hello{DeviceID: "kitchen"}) + if err != nil || m.Type != models.TypeReady { + t.Fatalf("dial: %v %+v", err, m) + } + if s.Asleep() { + t.Error("asleep with a speaker") + } + conn.Close("bye") + for !s.Asleep() { + select { + case <-ctx.Done(): + t.Fatal("still awake after the speaker left") + case <-time.After(10 * time.Millisecond): + } + } +} + var _ provider.Provider = (*textProvider)(nil) diff --git a/internal/server/panel.go b/internal/server/panel.go index f8fa6ef..bc50990 100644 --- a/internal/server/panel.go +++ b/internal/server/panel.go @@ -247,6 +247,7 @@ type panelState struct { Version string `json:"version"` Provider string `json:"provider"` Started time.Time `json:"started"` + Asleep bool `json:"asleep"` // no speaker is connected Tunable bool `json:"tunable"` Text bool `json:"text"` Saved bool `json:"saved"` // settings survive a restart @@ -278,6 +279,7 @@ func (s *Server) handleState(w http.ResponseWriter, _ *http.Request) { Version: s.cfg.Version, Provider: s.cfg.Provider.Name(), Started: s.started, + Asleep: s.Asleep(), Tunable: tunable, Text: provider.AcceptsText(s.cfg.Provider), Saved: s.cfg.SettingsFile != "", diff --git a/internal/server/panel/app.js b/internal/server/panel/app.js index 150cdcb..ae70e2d 100644 --- a/internal/server/panel/app.js +++ b/internal/server/panel/app.js @@ -208,6 +208,7 @@ function connectEvents() { if (!state.devices.some((d) => d.session === state.selected)) state.selected = state.devices[0]?.session ?? null; renderDevices(); renderDeviceFilter(); + renderServerLine(); }); es.addEventListener("history", (e) => { const ev = JSON.parse(e.data); @@ -381,7 +382,9 @@ $("#spotify-login").addEventListener("submit", async (e) => { function renderServerLine() { const i = state.info; - $("#server-line").textContent = `Сервер ${i.version || "dev"} · ${i.provider} · запущен ${fmtDate(i.started)} в ${fmtTime(i.started)}`; + const asleep = state.devices ? !state.devices.length : i.asleep; + $("#server-line").textContent = `Сервер ${i.version || "dev"} · ${i.provider} · запущен ${fmtDate(i.started)} в ${fmtTime(i.started)}` + + (asleep ? " · спит: нет подключённых колонок" : ""); } // ---------- devices ---------- @@ -1251,7 +1254,9 @@ function renderDevicePlugins() { if (!devs.length) list.append(el("li", { class: "muted", text: "Нет подключённых колонок." })); for (const d of devs) { let body; - if (!d.plugins) body = el("small", { text: "агент без плагинов: каталог не задан или агент устарел" }); + if (!d.plugins) body = el("small", { text: d.outdated + ? "агент устарел: перезагрузите колонку или выполните rc-service marusya-agent restart" + : "плагины выключены: задайте MARUSYA_PLUGINS_DIR в /etc/conf.d/marusya-agent и перезапустите агент" }); else if (!d.tools.length) body = el("small", { text: "плагины не объявили функций" }); else body = el("div", { class: "tools" }, ...d.tools.map((t) => el("code", { text: `${t.plugin}: ${t.name}`, title: t.description }))); const fromCatalog = (d.from_catalog || []).map((p) => el("div", { class: "row catalog-speaker" }, @@ -1382,7 +1387,8 @@ function speakerButtons(v) { const box = el("div", { class: "row catalog-speakers" }, el("small", { text: "На колонку:" })); for (const d of devs) { const have = v.speakers?.[d.device]; - if (!d.catalog) box.append(el("span", { class: "badge off", title: "Обновите агент на колонке", text: `${d.device}: агент устарел` })); + if (!d.catalog && d.outdated) box.append(el("span", { class: "badge off", title: "Перезагрузите колонку или пропишите в rc-service marusya-agent restart", text: `${d.device}: агент устарел` })); + else if (!d.catalog) box.append(el("span", { class: "badge off", title: "Задайте MARUSYA_PLUGINS_DIR в /etc/conf.d/marusya-agent (например, /tmp/marusya/plugins) и выполните rc-service marusya-agent restart", text: `${d.device}: плагины выключены` })); else if (have && have === v.latest_version) box.append(el("span", { class: "badge idle", text: `${d.device}: стоит` })); else box.append(el("button", { class: "btn tonal small", type: "button", text: have ? `${d.device}: обновить с v${have}` : d.device, diff --git a/internal/server/plugins.go b/internal/server/plugins.go index 9c7bf1a..9236db6 100644 --- a/internal/server/plugins.go +++ b/internal/server/plugins.go @@ -368,6 +368,9 @@ type devicePlugins struct { Catalog bool `json:"catalog"` // FromCatalog are the catalog plugins the speaker should run. FromCatalog []models.CatalogPlugin `json:"from_catalog"` + // Outdated means the agent is not the server's build, so a missing + // feature may be its age rather than its settings. + Outdated bool `json:"outdated,omitempty"` } func (s *Server) pluginsState() pluginsState { @@ -389,6 +392,7 @@ func (s *Server) pluginsState() pluginsState { Tools: append([]models.PluginTool{}, d.hello.PluginTools...), Catalog: d.supportsFeature(models.FeatureCatalogPlugins), FromCatalog: s.catalogPluginsFor(d.hello.DeviceID), + Outdated: d.outdated(), } st.Devices = append(st.Devices, dp) } diff --git a/internal/server/server.go b/internal/server/server.go index 6b221a0..a7a5d6f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -422,7 +422,11 @@ func newID() string { func (s *Server) register(d *deviceSession) { s.dmu.Lock() s.devices[d.id] = d + woke := len(s.devices) == 1 s.dmu.Unlock() + if woke { + s.cfg.Logger.Info("speaker connected; waking up") + } s.events.publish(Event{Kind: EventConnect, Session: d.id, Device: d.hello.DeviceID}) s.events.devicesChanged() } @@ -430,11 +434,25 @@ func (s *Server) register(d *deviceSession) { func (s *Server) unregister(d *deviceSession) { s.dmu.Lock() delete(s.devices, d.id) + asleep := len(s.devices) == 0 s.dmu.Unlock() + if asleep { + s.cfg.Logger.Info("no speakers connected; sleeping until one connects") + } s.events.publish(Event{Kind: EventDisconnect, Session: d.id, Device: d.hello.DeviceID}) s.events.devicesChanged() } +// Asleep reports that no speaker is connected. The server then has nothing +// to do: model and wake word sessions exist only per speaker, and requests +// of the ask API (hooks of Claude Code, Home Assistant) are skipped at once +// instead of waiting for a speaker. +func (s *Server) Asleep() bool { + s.dmu.Lock() + defer s.dmu.Unlock() + return len(s.devices) == 0 +} + // device returns the connected device with the given session ID. func (s *Server) device(id string) (*deviceSession, bool) { s.dmu.Lock() diff --git a/internal/server/session.go b/internal/server/session.go index 7d0ff83..a27cc74 100644 --- a/internal/server/session.go +++ b/internal/server/session.go @@ -178,11 +178,16 @@ func (d *deviceSession) info() DeviceInfo { LastTurn: d.lastTurn, CanSay: provider.AcceptsText(d.cfg.Provider) && slices.Contains(d.hello.Features, models.FeatureRemoteTurn), AgentVersion: d.hello.AgentVersion, - Outdated: buildinfo.Known(d.cfg.Version) && d.hello.AgentVersion != d.cfg.Version, + Outdated: d.outdated(), AutoUpdate: d.hello.AutoUpdate, } } +// outdated reports that the agent is not the server's build. +func (d *deviceSession) outdated() bool { + return buildinfo.Known(d.cfg.Version) && d.hello.AgentVersion != d.cfg.Version +} + func (d *deviceSession) run(ctx context.Context) error { ctx, cancel := context.WithCancel(ctx) defer cancel() diff --git a/scripts/catalog-upload.py b/scripts/catalog-upload.py new file mode 100644 index 0000000..d4447b5 --- /dev/null +++ b/scripts/catalog-upload.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +# 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. + +"""Uploads plugin archives to the plugin catalog (registry/). + +For every ZIP it reads plugin.json, creates the project (slug = id) if you +have none with that slug, uploads the version from plugin.json unless it is +already there and submits the project for review. With --publish (needs the +admin role) it publishes the project right away. + + python3 scripts/catalog-upload.py --email you@example.com bin/plugins/*.zip + +The password is asked for, or taken from $MARUSYA_CATALOG_PASSWORD. +Standard library only. +""" + +import argparse +import base64 +import getpass +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +import zipfile + + +class APIError(Exception): + def __init__(self, status, message): + super().__init__("HTTP %d: %s" % (status, message)) + self.status = status + + +class Catalog: + def __init__(self, url): + self.url = url.rstrip("/") + self.token = "" + + def call(self, method, path, body=None): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(self.url + path, data=data, method=method) + req.add_header("Accept", "application/json") + # Cloudflare's browser check refuses the default Python-urllib agent. + req.add_header("User-Agent", "marusya-catalog-upload/1.0 (+https://github.com/yawaflua/Gemini-for-Capsula)") + if data is not None: + req.add_header("Content-Type", "application/json") + if self.token: + req.add_header("Authorization", "Bearer " + self.token) + try: + with urllib.request.urlopen(req, timeout=120) as res: + raw = res.read() + except urllib.error.HTTPError as e: + raw = e.read() + try: + msg = json.loads(raw).get("message") or raw.decode(errors="replace") + except ValueError: + msg = raw.decode(errors="replace") + raise APIError(e.code, msg.strip()) from None + return json.loads(raw) if raw.strip() else {} + + def login(self, email, password): + res = self.call("POST", "/v1/auth/login", {"email": email, "password": password}) + self.token = res["tokens"]["access_token"] + return res["user"] + + def my_projects(self): + out, token = {}, "" + while True: + q = {"pageSize": 100} + if token: + q["pageToken"] = token + res = self.call("GET", "/v1/users/me/projects?" + urllib.parse.urlencode(q)) + for p in res.get("projects") or []: + out[p["slug"]] = p + token = res.get("next_page_token") or "" + if not token: + return out + + +def manifest(path): + """Returns plugin.json from the archive root or its single folder.""" + with zipfile.ZipFile(path) as z: + names = [n for n in z.namelist() if n.rstrip("/").split("/")[-1] == "plugin.json"] + names.sort(key=lambda n: n.count("/")) + if not names or names[0].count("/") > 1: + raise ValueError("no plugin.json in the archive root") + return json.loads(z.read(names[0])) + + +def upload(cat, path, projects, args): + man = manifest(path) + pid, version = man["id"], man.get("version") or "" + if not version: + raise ValueError("plugin.json has no version") + name = man.get("name") or pid + description = man.get("description") or name + + project = projects.get(pid) + if project is None: + project = cat.call("POST", "/v1/projects", { + "slug": pid, "name": name, "description": description, "git_url": args.git_url, + }) + projects[pid] = project + print(" проект создан") + + versions = cat.call("GET", "/v1/projects/%s/versions" % project["id"]).get("versions") or [] + if any(v.get("version") == version for v in versions): + print(" версия %s уже есть" % version) + else: + with open(path, "rb") as f: + data = base64.b64encode(f.read()).decode() + cat.call("POST", "/v1/projects/%s/versions" % project["id"], { + "version": version, + "description": description, + "changelog": args.changelog, + "readme": "", + "tag": "VERSION_TAG_RELEASE", + "metadata": { + "slug": pid, "description": description, + "licenses": [args.license], "authors": args.author, + "abi_version": "1", "api_version": "1", + }, + "archive": {"content_type": "application/zip", "data": data}, + }) + print(" версия %s загружена" % version) + + status = cat.call("GET", "/v1/projects/id/%s" % project["id"]).get("status", "") + if status in ("PROJECT_STATUS_DRAFT", "PROJECT_STATUS_REJECTED", "", None): + cat.call("POST", "/v1/projects/%s:submit" % project["id"], {"comment": args.comment}) + status = "PROJECT_STATUS_PENDING_REVIEW" + print(" отправлен на модерацию") + if args.publish and status != "PROJECT_STATUS_PUBLISHED": + cat.call("POST", "/v1/admin/projects/%s:set-status" % project["id"], + {"status": "PROJECT_STATUS_PUBLISHED", "reason": args.comment}) + print(" опубликован") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("archives", nargs="+", help="ZIP archives with plugin.json") + ap.add_argument("--registry", default=os.environ.get("MARUSYA_PLUGIN_REGISTRY", "https://marusya.ywfl.dev")) + ap.add_argument("--email", required=True) + ap.add_argument("--git-url", default="https://github.com/yawaflua/Gemini-for-Capsula") + ap.add_argument("--license", default="Apache-2.0") + ap.add_argument("--author", action="append", default=None, help="repeatable; default: your nickname") + ap.add_argument("--changelog", default="Первая версия.") + ap.add_argument("--comment", default="Пример из репозитория Маруси.") + ap.add_argument("--publish", action="store_true", help="publish at once (admin only)") + args = ap.parse_args() + + password = os.environ.get("MARUSYA_CATALOG_PASSWORD") or getpass.getpass("Пароль от %s: " % args.registry) + cat = Catalog(args.registry) + try: + user = cat.login(args.email, password) + except APIError as e: + sys.exit("вход не удался: %s" % e) + args.author = args.author or [user.get("username") or args.email] + projects = cat.my_projects() + + failed = 0 + for path in args.archives: + print(os.path.basename(path)) + try: + upload(cat, path, projects, args) + except (APIError, ValueError, OSError, KeyError, zipfile.BadZipFile) as e: + failed += 1 + print(" ошибка: %s" % e) + sys.exit(1 if failed else 0) + + +if __name__ == "__main__": + main()