6 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Fable 5 7c7ce1541c release v0.2.2
Test, Build and Push / test-gateway (push) Successful in 9s
Test, Build and Push / release (push) Successful in 3s
Test, Build and Push / build-gateway (push) Successful in 48s
Network migration: the gateway's default Tatlock URL is now the docker
container name (http://tatlock:8000); the retiring tatlock.schweitz.internal
domain is gone from config and docs. Deployments that set
DESKLOCK_TATLOCK_BASE_URL are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:32:13 +02:00
jpmschweitzerandClaude Fable 5 014c86f51e chore(gateway): drop schweitz.internal, default to http://tatlock:8000
Test, Build and Push / test-gateway (push) Successful in 11s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
The homelab is retiring the *.schweitz.internal domain; in-network
machine-to-machine traffic uses docker container names on the
docker-dataplane network. The deployed tatlock-ui stack already overrides
DESKLOCK_TATLOCK_BASE_URL (Tatlock runs on the host), so only the
fallback default changes.

Docs follow: AGENTS.md M2M guidance now points at container names with
*.schweitz.net reserved for browsers, architecture.md drops the retired
domain (the registry name now matches what CI actually pushes since
c477019), and the README diagram loses the stale hostname.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:22:25 +02:00
jpmschweitzerandClaude Fable 5 c4770194d9 chore(ci): push images via git.schweitz.net registry
Test, Build and Push / test-gateway (push) Successful in 59s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
The .internal registry domain is being retired; git.schweitz.net now
serves the registry without SSO on /v2/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:09:59 +02:00
jpmschweitzerandClaude Opus 4.8 ccb46d45b4 release v0.2.1
Test, Build and Push / test-gateway (push) Successful in 11s
Test, Build and Push / release (push) Successful in 2s
Test, Build and Push / build-gateway (push) Successful in 1m27s
Fixes: volume voice command no longer hangs in the thinking spinner (gong
stops holding the busy state), and the butler filler is reworded to avoid
a text-to-speech mid-phrase pause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
2026-07-15 22:39:46 +02:00
jpmschweitzerandClaude Opus 4.8 9a62233775 fix(gateway): reword butler filler to avoid a TTS mid-phrase pause
Kokoro inserts an unnatural ~0.2s pause before "for you", so "Let me check
on that for you, sir." came out as two phrases. Reword to "Let me check on
that, sir." — same intent, clean pacing (measured: no internal silence gap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
2026-07-15 22:39:03 +02:00
jpmschweitzerandClaude Opus 4.8 24e9375095 fix(firmware): don't let the feedback gong swallow return-to-idle
A spoken volume command left the face stuck in the thinking spinner. The
gateway sends state:thinking -> command -> state:idle, but the device's
state:idle handler is gated on !audio_is_playing(), and the feedback gong
had been setting s_playing for its (up to 5 s) duration — so the idle was
ignored and nothing re-sent it. The gong is a UI cue, not reply playback,
so it no longer sets s_playing. This also drops the 5 s wake-gate the gong
was imposing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
2026-07-15 21:50:53 +02:00
8 changed files with 40 additions and 18 deletions
+3 -3
View File
@@ -49,7 +49,7 @@ jobs:
- name: Login to Gitea Registry - name: Login to Gitea Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: git.schweitz.internal registry: git.schweitz.net
username: ${{ secrets.REGISTRY_USER }} username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }} password: ${{ secrets.REGISTRY_PASSWORD }}
@@ -61,8 +61,8 @@ jobs:
provenance: false provenance: false
sbom: false sbom: false
tags: | tags: |
git.schweitz.internal/jpmschweitzer/desklock-gateway:latest git.schweitz.net/jpmschweitzer/desklock-gateway:latest
git.schweitz.internal/jpmschweitzer/desklock-gateway:${{ github.ref_name }} git.schweitz.net/jpmschweitzer/desklock-gateway:${{ github.ref_name }}
- name: Trigger Watchtower update - name: Trigger Watchtower update
if: success() if: success()
+6 -5
View File
@@ -6,8 +6,8 @@
## What this project is ## What this project is
DeskLock is the living-room visual/audio endpoint for **Tatlock**, the homelab butler DeskLock is the living-room visual/audio endpoint for **Tatlock**, the homelab butler
(`/mnt/media/Projects/tatlock`, API at `http://tatlock.schweitz.internal:8000`). Two halves, (`/mnt/media/Projects/tatlock`, API at `http://tatlock:8000` on `docker-dataplane`).
one repo: Two halves, one repo:
- `firmware/` — ESP-IDF (C, LVGL 9) app for the Waveshare ESP32-P4-WIFI6-Touch-LCD-3.4C - `firmware/` — ESP-IDF (C, LVGL 9) app for the Waveshare ESP32-P4-WIFI6-Touch-LCD-3.4C
(3.4" round 800×800 touch display, dual mics + ES7210 AEC, ES8311 codec + speaker). (3.4" round 800×800 touch display, dual mics + ES7210 AEC, ES8311 codec + speaker).
@@ -124,7 +124,8 @@ make typecheck # mypy
## Homelab context ## Homelab context
- This server **is** tower-of-joy; the device, gateway, and Tatlock all share the LAN. - This server **is** tower-of-joy; the device, gateway, and Tatlock all share the LAN.
- Use `tatlock.schweitz.internal:8000` (direct, no SSO) — the public - Machine-to-machine traffic uses container names on the `docker-dataplane` network —
`tatlock.schweitz.net` route sits behind Authentik and is not for machine-to-machine `http://tatlock:8000` (the deployed stack overrides `DESKLOCK_TATLOCK_BASE_URL` to
traffic. the host LAN IP while Tatlock runs on the host). Browser-facing URLs use
`https://<name>.schweitz.net` (NPM, Authentik SSO) — never for M2M traffic.
- Git remote: `git.schweitz.net` (Gitea). - Git remote: `git.schweitz.net` (Gitea).
+19
View File
@@ -8,6 +8,25 @@ until the first tagged release.
## [Unreleased] ## [Unreleased]
## [0.2.2] — 2026-07-19
### Changed
- The gateway's default Tatlock URL is now `http://tatlock:8000` (docker
container name) — the retiring `tatlock.schweitz.internal` domain is gone
from config and docs. Deployments setting `DESKLOCK_TATLOCK_BASE_URL` are
unaffected.
## [0.2.1] — 2026-07-15
### Fixed
- A spoken volume command no longer leaves the face stuck in the thinking
spinner — the feedback gong is a UI cue and no longer holds the busy state,
so the return-to-idle isn't swallowed.
- The butler filler line reads as one phrase instead of two — reworded to "Let
me check on that, sir." to avoid a text-to-speech pause before "for you".
## [0.2.0] — 2026-07-15 ## [0.2.0] — 2026-07-15
### Added ### Added
+2 -2
View File
@@ -38,8 +38,8 @@ happens on this server.
▼ │ Speaches (container, GPU) │ ▼ │ Speaches (container, GPU) │
┌────────────────────┐ │ • STT: faster-whisper │ ┌────────────────────┐ │ • STT: faster-whisper │
│ Tatlock (butler) │ │ • TTS: Kokoro / Piper │ │ Tatlock (butler) │ │ • TTS: Kokoro / Piper │
tatlock.schweitz. │ │ also usable by Open WebUI, │ http://tatlock │ │ also usable by Open WebUI, │
internal :8000 │ │ Home Assistant, … │ :8000 │ │ Home Assistant, … │
└────────────────────┘ └─────────────────────────────┘ └────────────────────┘ └─────────────────────────────┘
``` ```
+2 -2
View File
@@ -74,7 +74,7 @@ models — the container stays a slim pure-Python image with no CUDA/ML dependen
2. Buffers inbound PCM until end-of-utterance (client-signalled in phase 1; VAD later). 2. Buffers inbound PCM until end-of-utterance (client-signalled in phase 1; VAD later).
3. **STT**: POST to Speaches `/v1/audio/transcriptions`. 3. **STT**: POST to Speaches `/v1/audio/transcriptions`.
4. **Chat**: POST the transcript to Tatlock `/v1/chat/completions` 4. **Chat**: POST the transcript to Tatlock `/v1/chat/completions`
(`http://tatlock.schweitz.internal:8000`, OpenAI-compatible, **streaming**), (`http://tatlock:8000`, OpenAI-compatible, **streaming**),
maintaining the conversation history so follow-ups have context. maintaining the conversation history so follow-ups have context.
5. **TTS**: as Tatlock's token stream completes each sentence, POST it to Speaches 5. **TTS**: as Tatlock's token stream completes each sentence, POST it to Speaches
`/v1/audio/speech` and forward the PCM immediately — see `/v1/audio/speech` and forward the PCM immediately — see
@@ -335,7 +335,7 @@ Gitea Actions (`.gitea/workflows/build.yml`), following the tatlock/tatlock-ui p
- **Every push to `main`**: lint + tests for the gateway (Python 3.12). - **Every push to `main`**: lint + tests for the gateway (Python 3.12).
- **Version tags (`v0.1.0`, …)**: tests, then build `gateway/` into - **Version tags (`v0.1.0`, …)**: tests, then build `gateway/` into
`git.schweitz.internal/jpmschweitzer/desklock-gateway:{latest,tag}`, push to the `git.schweitz.net/jpmschweitzer/desklock-gateway:{latest,tag}`, push to the
Gitea registry, create a release, and trigger Watchtower to roll the running Gitea registry, create a release, and trigger Watchtower to roll the running
container. container.
- Required repo/org secrets: `REGISTRY_USER`, `REGISTRY_PASSWORD`, - Required repo/org secrets: `REGISTRY_USER`, `REGISTRY_PASSWORD`,
+4 -3
View File
@@ -161,8 +161,10 @@ static void audio_task(void *arg)
* returns, preventing a playback-boundary false wake. */ * returns, preventing a playback-boundary false wake. */
if (job == JOB_GONG && s_gong != NULL) { if (job == JOB_GONG && s_gong != NULL) {
/* Play in chunks so a new request (a fresh volume change) cuts the /* Play in chunks so a new request (a fresh volume change) cuts the
* current gong off and restarts, instead of queueing another 5 s. */ * current gong off and restarts, instead of queueing another 5 s.
s_playing = true; * Note: the gong is a UI cue, not "playback" — it deliberately does
* NOT set s_playing, so a following state:idle isn't swallowed (that
* guard is for reply audio) and it doesn't gate wake for 5 s. */
uint32_t gen; uint32_t gen;
do { do {
gen = s_gong_gen; gen = s_gong_gen;
@@ -172,7 +174,6 @@ static void audio_task(void *arg)
esp_codec_dev_write(s_spk, s_gong + off, n * sizeof(int16_t)); esp_codec_dev_write(s_spk, s_gong + off, n * sizeof(int16_t));
} }
} while (s_gong_gen != gen); /* a new request arrived mid-play → restart */ } while (s_gong_gen != gen); /* a new request arrived mid-play → restart */
s_playing = false;
s_gong_active = false; s_gong_active = false;
} else if (job == JOB_CHIME && s_chime != NULL) { } else if (job == JOB_CHIME && s_chime != NULL) {
s_playing = true; s_playing = true;
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "desklock-gateway" name = "desklock-gateway"
version = "0.2.0" version = "0.2.2"
description = "Voice gateway bridging the DeskLock device to the Tatlock butler (STT/chat/TTS)" description = "Voice gateway bridging the DeskLock device to the Tatlock butler (STT/chat/TTS)"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+3 -2
View File
@@ -4,7 +4,7 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings): class Settings(BaseSettings):
"""Gateway configuration, overridable via DESKLOCK_* environment variables.""" """Gateway configuration, overridable via DESKLOCK_* environment variables."""
tatlock_base_url: str = "http://tatlock.schweitz.internal:8000" tatlock_base_url: str = "http://tatlock:8000"
tatlock_model: str = "Tatlock" tatlock_model: str = "Tatlock"
# PCM rate of the device WebSocket contract (docs/architecture.md) # PCM rate of the device WebSocket contract (docs/architecture.md)
@@ -20,7 +20,8 @@ class Settings(BaseSettings):
tts_voice: str = "bm_george" tts_voice: str = "bm_george"
# Spoken immediately after a query is accepted, to fill the long Tatlock wait. # Spoken immediately after a query is accepted, to fill the long Tatlock wait.
filler_text: str = "Let me check on that for you, sir." # Avoid "for you" — Kokoro inserts an unnatural mid-phrase pause before it.
filler_text: str = "Let me check on that, sir."
# embedded fallback only (requires the [speech] extra) # embedded fallback only (requires the [speech] extra)
embedded_stt_model: str = "small" embedded_stt_model: str = "small"