| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311 |
- """NDJSON control socket for the assistant (clients: TUI and hotkey CLI).
- Bidirectional, unlike the STT bus: clients send commands and may `subscribe` as a
- viewer to receive the assistant's broadcast events. All outbound traffic for a
- client goes through that client's bounded queue and its own writer thread, so a
- slow client never blocks the service.
- """
- import collections
- import itertools
- import json
- import logging
- import os
- import socket
- import threading
- import time
- from pathlib import Path
- from .. import events
- from .service import EXECUTE_BUSY
- logger = logging.getLogger(__name__)
- CLIENT_QUEUE = 256
- POLL_INTERVAL = 0.005
- ACCEPT_TIMEOUT = 0.2
- READ_CHUNK = 65536
- def default_control_path() -> str:
- """Default control socket path under $XDG_RUNTIME_DIR (fallback /tmp)."""
- runtime = os.environ.get("XDG_RUNTIME_DIR") or "/tmp"
- return str(Path(runtime) / "stt-assistant.sock")
- class _Client:
- __slots__ = ("conn", "queue", "viewer", "writer", "reader")
- def __init__(self, conn: socket.socket, queue_size: int):
- self.conn = conn
- self.queue: collections.deque[bytes] = collections.deque(maxlen=queue_size)
- self.viewer = False
- self.writer: threading.Thread | None = None
- self.reader: threading.Thread | None = None
- class ControlServer:
- """Serves the assistant control protocol over a UNIX socket."""
- def __init__(self, cfg, service, path: str | None = None):
- """Create the server (call `start()` to bind).
- Args:
- cfg: Configuration (uses `[assistant]`).
- service: The `AssistantService` this server drives.
- path: Socket path; defaults to config or `default_control_path()`.
- """
- self.cfg = cfg
- self.service = service
- self.path = path or cfg.assistant.control_socket or default_control_path()
- self._seq = itertools.count(1)
- self._clients: list[_Client] = []
- self._lock = threading.Lock()
- self._stop = threading.Event()
- self._server: socket.socket | None = None
- self._accept: threading.Thread | None = None
- # ------------------------------------------------------------------ lifecycle
- def start(self) -> None:
- """Bind the socket and start accepting clients."""
- self._bind()
- self._accept = threading.Thread(
- target=self._accept_loop, name="control-accept", daemon=True
- )
- self._accept.start()
- def _bind(self) -> None:
- path = Path(self.path)
- path.parent.mkdir(parents=True, exist_ok=True)
- if path.exists():
- path.unlink()
- server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
- server.bind(str(path))
- os.chmod(str(path), 0o600)
- server.listen(16)
- server.settimeout(ACCEPT_TIMEOUT)
- self._server = server
- def close(self) -> None:
- """Stop accepting, drop clients, and remove the socket."""
- self._stop.set()
- if self._server is not None:
- try:
- self._server.close()
- except OSError:
- pass
- with self._lock:
- clients = list(self._clients)
- self._clients.clear()
- for client in clients:
- self._shutdown(client)
- if self._accept is not None:
- self._accept.join(timeout=1)
- try:
- Path(self.path).unlink()
- except OSError:
- pass
- # ------------------------------------------------------------------ queries
- def client_count(self) -> int:
- with self._lock:
- return len(self._clients)
- def viewer_count(self) -> int:
- with self._lock:
- return sum(1 for client in self._clients if client.viewer)
- # ------------------------------------------------------------------ outbound
- def broadcast(self, type_: str, **fields) -> None:
- """Send an assistant event to every subscribed viewer."""
- self._enqueue_viewers(events.make_event(type_, next(self._seq), **fields))
- def relay(self, event: dict) -> None:
- """Forward a raw STT event to every subscribed viewer."""
- self._enqueue_viewers(event)
- def _enqueue_viewers(self, event: dict) -> None:
- line = events.encode(event)
- with self._lock:
- clients = [c for c in self._clients if c.viewer]
- for client in clients:
- client.queue.append(line)
- def _send(self, client: _Client, type_: str, **fields) -> None:
- client.queue.append(events.encode(events.make_event(type_, next(self._seq), **fields)))
- # ------------------------------------------------------------------ accept/IO
- def _accept_loop(self) -> None:
- server = self._server
- if server is None:
- return
- while not self._stop.is_set():
- try:
- conn, _ = server.accept()
- except socket.timeout:
- continue
- except OSError:
- break
- client = _Client(conn, CLIENT_QUEUE)
- with self._lock:
- self._clients.append(client)
- client.writer = threading.Thread(
- target=self._writer, args=(client,), name="control-writer", daemon=True
- )
- client.reader = threading.Thread(
- target=self._reader, args=(client,), name="control-reader", daemon=True
- )
- client.writer.start()
- client.reader.start()
- def _writer(self, client: _Client) -> None:
- try:
- while not self._stop.is_set():
- try:
- line = client.queue.popleft()
- except IndexError:
- time.sleep(POLL_INTERVAL)
- continue
- client.conn.sendall(line)
- except OSError:
- pass
- finally:
- self._drop(client)
- def _reader(self, client: _Client) -> None:
- buffer = bytearray()
- try:
- while not self._stop.is_set():
- try:
- chunk = client.conn.recv(READ_CHUNK)
- except OSError:
- break
- if not chunk:
- break
- buffer.extend(chunk)
- while True:
- newline = buffer.find(b"\n")
- if newline == -1:
- break
- line = bytes(buffer[:newline])
- del buffer[: newline + 1]
- if line.strip():
- self._handle_line(client, line)
- except OSError:
- pass
- finally:
- self._drop(client)
- def _drop(self, client: _Client) -> None:
- last_viewer = False
- with self._lock:
- if client in self._clients:
- self._clients.remove(client)
- # Losing the last viewer ends an unattended chat. Decide under the
- # lock, then notify after releasing it: viewer_detached() emits,
- # and emitting re-enters this non-reentrant lock.
- last_viewer = client.viewer and not any(c.viewer for c in self._clients)
- self._shutdown(client)
- if last_viewer:
- self.service.viewer_detached()
- def _shutdown(self, client: _Client) -> None:
- try:
- client.conn.shutdown(socket.SHUT_RDWR)
- except OSError:
- pass
- try:
- client.conn.close()
- except OSError:
- pass
- # ------------------------------------------------------------------ dispatch
- def _handle_line(self, client: _Client, line: bytes) -> None:
- try:
- message = json.loads(line.decode("utf-8"))
- except (UnicodeDecodeError, json.JSONDecodeError):
- self._send(client, "error", code="bad_message", message="invalid JSON")
- return
- if not isinstance(message, dict) or "type" not in message:
- self._send(
- client, "error", code="bad_message", message="expected an object with a type"
- )
- return
- self._dispatch(client, message)
- def _dispatch(self, client: _Client, message: dict) -> None:
- command = message.get("type")
- service = self.service
- if command == "subscribe":
- client.viewer = bool(message.get("viewer", False))
- self._send_status(client)
- elif command == "status":
- self._send_status(client)
- elif command == "command_start":
- self._ack_or_error(client, command, service.arm(), "cannot start a command now")
- elif command == "command_cancel":
- self._ack_or_error(client, command, service.cancel(), "no command to cancel")
- elif command == "command_edit":
- ok = service.edit(str(message.get("text", "")))
- self._ack_or_error(client, command, ok, "no command to edit")
- elif command == "command_execute":
- self._execute(client, command)
- elif command == "chat_toggle":
- self._ack_or_error(client, command, service.toggle_chat(), "cannot toggle chat now")
- elif command == "chat_send":
- ok = service.submit_chat_text(str(message.get("text", "")))
- self._ack_or_error(client, command, ok, "cannot send chat now")
- elif command == "chat_capture":
- ok = service.set_chat_capture(bool(message.get("on", True)))
- self._ack_or_error(client, command, ok, "cannot set chat capture")
- elif command == "triage_chat":
- ok = service.set_triage_chat(bool(message.get("on", False)))
- self._ack_or_error(client, command, ok, "cannot set triage chat")
- elif command == "clear":
- self._ack_or_error(client, command, service.clear(), "nothing to clear")
- elif command == "speaker_list":
- self._send(client, "speaker_list", speakers=service.speaker_list())
- elif command == "speaker_name":
- speaker_id = str(message.get("speaker_id", ""))
- name = str(message.get("name", ""))
- if not name:
- self._send(client, "error", code="bad_message", message="name is required")
- elif service.speaker_name(speaker_id, name):
- self._send(client, "ack", command="speaker_name", ok=True)
- else:
- self._send(
- client,
- "error",
- code="unknown_speaker",
- message=f"unknown speaker: {speaker_id}",
- )
- else:
- self._send(client, "error", code="unknown_command", message=f"unknown: {command}")
- def _execute(self, client: _Client, command: str) -> None:
- if self.cfg.assistant.require_viewer and self.viewer_count() == 0:
- self._send(client, "error", code="no_viewer", message="no viewer attached")
- return
- result = self.service.execute()
- if result is EXECUTE_BUSY:
- self._send(client, "error", code="busy", message="inference queue full")
- return
- if result is None:
- self._send(client, "error", code="not_ready", message="command is not ready")
- return
- self._send(client, "ack", command=command, ok=True)
- def _ack_or_error(self, client: _Client, command: str, ok: bool, reason: str) -> None:
- if ok:
- self._send(client, "ack", command=command, ok=True)
- else:
- self._send(client, "error", code="refused", message=reason)
- def _send_status(self, client: _Client) -> None:
- self._send(client, "status", **self.service.status())
|