The gate had no `select`, so it linted with whatever the installed ruff version defaults to. `dev` pins only `ruff>=0.6` and CI installs that extra fresh on every run, which made the rule set a function of when pip last resolved rather than of this code. Two developers on one commit could get different answers, and so could CI and a laptop. This surfaced when T-47's converged setup reinstalled ruff and pulled 0.16.3: `make lint` failed on UP017 and BLE001 in main.py, a file the commit before it had not touched. Under the previous install the same code passed. Nothing about the code changed — only the linter's idea of what to look at, which had grown to 413 rules with nobody choosing them. Naming the families fixes that; pinning the version would only have frozen the symptom and moved the surprise to whoever unpinned it. 217 rules now, selected on purpose, and a future ruff release becomes a decision instead of a broken push. ASYNC is included deliberately — this is a websocket gateway, and it is the family whose findings would be real bugs rather than style. BLE is deliberately excluded: main.py catches bare Exception when a device disappears mid-send, which is correct there, and selecting BLE would mean a noqa on every such site to say so. UP017 is fixed rather than suppressed (datetime.timezone.utc -> UTC, identical semantics, and requires-python is already >=3.11); isort then reordered the import, which is the whole of the main.py diff. Verified the selection is load-bearing rather than decorative: a probe file with a mutable default argument fails the explicit set (B006, exit 1) and passes ruff's minimal default set (exit 0), so the rules named here are doing work the fallback would not. Probe deleted; lint, typecheck and the 9-test suite all green after. Co-Authored-By: Claude <noreply@anthropic.com>
149 lines
5.4 KiB
Python
149 lines
5.4 KiB
Python
"""DeskLock gateway: WebSocket voice bridge between the device and Tatlock.
|
|
|
|
Protocol (see docs/architecture.md — keep in sync):
|
|
device -> gateway : {"type": "utterance_start"} + binary PCM + {"type": "utterance_end"}
|
|
gateway -> device : state / transcript / reply_text events, then audio_start + PCM + audio_end
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import UTC, datetime
|
|
|
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
|
|
from . import commands, stt, tts
|
|
from .config import settings
|
|
from .tatlock import TatlockClient
|
|
|
|
logger = logging.getLogger("desklock.gateway")
|
|
|
|
app = FastAPI(title="DeskLock Gateway")
|
|
|
|
PCM_CHUNK_BYTES = 4096
|
|
|
|
# device registry: every endpoint that ever connected, keyed by client IP
|
|
DEVICES: dict[str, dict] = {}
|
|
|
|
# Butler filler audio (the "let me check…" line) is synthesized once, then replayed.
|
|
_filler_pcm: bytes | None = None
|
|
_filler_tried = False
|
|
|
|
|
|
async def _ensure_filler() -> bytes | None:
|
|
global _filler_pcm, _filler_tried
|
|
if not _filler_tried:
|
|
_filler_tried = True
|
|
try:
|
|
_filler_pcm = await asyncio.to_thread(tts.synthesize, settings.filler_text)
|
|
except Exception:
|
|
logger.exception("filler synth failed; continuing without it")
|
|
return _filler_pcm
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(UTC).astimezone().isoformat(timespec="seconds")
|
|
|
|
|
|
@app.get("/healthz")
|
|
async def healthz() -> dict[str, str]:
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.get("/devices")
|
|
async def devices() -> dict[str, dict]:
|
|
return DEVICES
|
|
|
|
|
|
@app.websocket("/ws/voice")
|
|
async def voice(ws: WebSocket) -> None:
|
|
await ws.accept()
|
|
ip = ws.client.host if ws.client else "unknown"
|
|
device = DEVICES.setdefault(ip, {"connections": 0})
|
|
device["connections"] += 1
|
|
device.update(connected=True, connected_at=_now(), last_seen=_now())
|
|
logger.info("device connected from %s (connection #%d)", ip, device["connections"])
|
|
|
|
tatlock = TatlockClient()
|
|
pcm = bytearray()
|
|
recording = False
|
|
try:
|
|
while True:
|
|
message = await ws.receive()
|
|
if message["type"] == "websocket.disconnect":
|
|
raise WebSocketDisconnect(message.get("code", 1000))
|
|
device["last_seen"] = _now()
|
|
if "bytes" in message and message["bytes"] is not None:
|
|
if recording:
|
|
pcm.extend(message["bytes"])
|
|
continue
|
|
if "text" not in message or message["text"] is None:
|
|
continue
|
|
|
|
import json
|
|
|
|
event = json.loads(message["text"])
|
|
if event["type"] == "hello":
|
|
device["device"] = event.get("device", "unknown")
|
|
device["fw"] = event.get("fw", "unknown")
|
|
logger.info("device identified: %s fw=%s", device["device"], device["fw"])
|
|
elif event["type"] == "utterance_start":
|
|
pcm.clear()
|
|
recording = True
|
|
elif event["type"] == "utterance_end":
|
|
recording = False
|
|
try:
|
|
await _handle_utterance(ws, tatlock, bytes(pcm))
|
|
except WebSocketDisconnect:
|
|
raise
|
|
except Exception:
|
|
logger.exception("utterance failed")
|
|
try:
|
|
await ws.send_json({"type": "error"})
|
|
await ws.send_json({"type": "state", "value": "idle"})
|
|
except Exception:
|
|
logger.info("device gone before error could be reported")
|
|
break
|
|
except WebSocketDisconnect:
|
|
logger.info("device disconnected: %s", ip)
|
|
finally:
|
|
device.update(connected=False, disconnected_at=_now())
|
|
await tatlock.aclose()
|
|
|
|
|
|
async def _handle_utterance(ws: WebSocket, tatlock: TatlockClient, pcm: bytes) -> None:
|
|
await ws.send_json({"type": "state", "value": "thinking"})
|
|
|
|
transcript = await asyncio.to_thread(stt.transcribe, pcm, settings.sample_rate)
|
|
await ws.send_json({"type": "transcript", "text": transcript})
|
|
if not transcript:
|
|
await ws.send_json({"type": "state", "value": "idle"})
|
|
return
|
|
|
|
# Simple device commands (volume/mute) bypass the LLM entirely.
|
|
command = commands.match(transcript)
|
|
if command is not None:
|
|
await ws.send_json(command)
|
|
await ws.send_json({"type": "state", "value": "idle"})
|
|
return
|
|
|
|
# Acknowledge immediately with a canned line so the long Tatlock wait isn't dead
|
|
# air, then re-assert "thinking" to keep the spinner up while it works.
|
|
filler = await _ensure_filler()
|
|
if filler:
|
|
await ws.send_json({"type": "reply_text", "text": settings.filler_text})
|
|
await ws.send_json({"type": "audio_start", "sample_rate": settings.sample_rate})
|
|
for offset in range(0, len(filler), PCM_CHUNK_BYTES):
|
|
await ws.send_bytes(filler[offset : offset + PCM_CHUNK_BYTES])
|
|
await ws.send_json({"type": "audio_end"})
|
|
await ws.send_json({"type": "state", "value": "thinking"})
|
|
|
|
reply = await tatlock.ask(transcript)
|
|
await ws.send_json({"type": "reply_text", "text": reply})
|
|
|
|
audio = await asyncio.to_thread(tts.synthesize, reply)
|
|
await ws.send_json({"type": "audio_start", "sample_rate": settings.sample_rate})
|
|
for offset in range(0, len(audio), PCM_CHUNK_BYTES):
|
|
await ws.send_bytes(audio[offset : offset + PCM_CHUNK_BYTES])
|
|
await ws.send_json({"type": "audio_end"})
|
|
await ws.send_json({"type": "state", "value": "idle"})
|