control.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. """NDJSON control socket for the assistant (clients: TUI and hotkey CLI).
  2. Bidirectional, unlike the STT bus: clients send commands and may `subscribe` as a
  3. viewer to receive the assistant's broadcast events. All outbound traffic for a
  4. client goes through that client's bounded queue and its own writer thread, so a
  5. slow client never blocks the service.
  6. """
  7. import collections
  8. import itertools
  9. import json
  10. import logging
  11. import os
  12. import socket
  13. import threading
  14. import time
  15. from pathlib import Path
  16. from .. import events
  17. from .service import EXECUTE_BUSY
  18. logger = logging.getLogger(__name__)
  19. CLIENT_QUEUE = 256
  20. POLL_INTERVAL = 0.005
  21. ACCEPT_TIMEOUT = 0.2
  22. READ_CHUNK = 65536
  23. def default_control_path() -> str:
  24. """Default control socket path under $XDG_RUNTIME_DIR (fallback /tmp)."""
  25. runtime = os.environ.get("XDG_RUNTIME_DIR") or "/tmp"
  26. return str(Path(runtime) / "stt-assistant.sock")
  27. class _Client:
  28. __slots__ = ("conn", "queue", "viewer", "writer", "reader")
  29. def __init__(self, conn: socket.socket, queue_size: int):
  30. self.conn = conn
  31. self.queue: collections.deque[bytes] = collections.deque(maxlen=queue_size)
  32. self.viewer = False
  33. self.writer: threading.Thread | None = None
  34. self.reader: threading.Thread | None = None
  35. class ControlServer:
  36. """Serves the assistant control protocol over a UNIX socket."""
  37. def __init__(self, cfg, service, path: str | None = None):
  38. """Create the server (call `start()` to bind).
  39. Args:
  40. cfg: Configuration (uses `[assistant]`).
  41. service: The `AssistantService` this server drives.
  42. path: Socket path; defaults to config or `default_control_path()`.
  43. """
  44. self.cfg = cfg
  45. self.service = service
  46. self.path = path or cfg.assistant.control_socket or default_control_path()
  47. self._seq = itertools.count(1)
  48. self._clients: list[_Client] = []
  49. self._lock = threading.Lock()
  50. self._stop = threading.Event()
  51. self._server: socket.socket | None = None
  52. self._accept: threading.Thread | None = None
  53. # ------------------------------------------------------------------ lifecycle
  54. def start(self) -> None:
  55. """Bind the socket and start accepting clients."""
  56. self._bind()
  57. self._accept = threading.Thread(
  58. target=self._accept_loop, name="control-accept", daemon=True
  59. )
  60. self._accept.start()
  61. def _bind(self) -> None:
  62. path = Path(self.path)
  63. path.parent.mkdir(parents=True, exist_ok=True)
  64. if path.exists():
  65. path.unlink()
  66. server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
  67. server.bind(str(path))
  68. os.chmod(str(path), 0o600)
  69. server.listen(16)
  70. server.settimeout(ACCEPT_TIMEOUT)
  71. self._server = server
  72. def close(self) -> None:
  73. """Stop accepting, drop clients, and remove the socket."""
  74. self._stop.set()
  75. if self._server is not None:
  76. try:
  77. self._server.close()
  78. except OSError:
  79. pass
  80. with self._lock:
  81. clients = list(self._clients)
  82. self._clients.clear()
  83. for client in clients:
  84. self._shutdown(client)
  85. if self._accept is not None:
  86. self._accept.join(timeout=1)
  87. try:
  88. Path(self.path).unlink()
  89. except OSError:
  90. pass
  91. # ------------------------------------------------------------------ queries
  92. def client_count(self) -> int:
  93. with self._lock:
  94. return len(self._clients)
  95. def viewer_count(self) -> int:
  96. with self._lock:
  97. return sum(1 for client in self._clients if client.viewer)
  98. # ------------------------------------------------------------------ outbound
  99. def broadcast(self, type_: str, **fields) -> None:
  100. """Send an assistant event to every subscribed viewer."""
  101. self._enqueue_viewers(events.make_event(type_, next(self._seq), **fields))
  102. def relay(self, event: dict) -> None:
  103. """Forward a raw STT event to every subscribed viewer."""
  104. self._enqueue_viewers(event)
  105. def _enqueue_viewers(self, event: dict) -> None:
  106. line = events.encode(event)
  107. with self._lock:
  108. clients = [c for c in self._clients if c.viewer]
  109. for client in clients:
  110. client.queue.append(line)
  111. def _send(self, client: _Client, type_: str, **fields) -> None:
  112. client.queue.append(events.encode(events.make_event(type_, next(self._seq), **fields)))
  113. # ------------------------------------------------------------------ accept/IO
  114. def _accept_loop(self) -> None:
  115. server = self._server
  116. if server is None:
  117. return
  118. while not self._stop.is_set():
  119. try:
  120. conn, _ = server.accept()
  121. except socket.timeout:
  122. continue
  123. except OSError:
  124. break
  125. client = _Client(conn, CLIENT_QUEUE)
  126. with self._lock:
  127. self._clients.append(client)
  128. client.writer = threading.Thread(
  129. target=self._writer, args=(client,), name="control-writer", daemon=True
  130. )
  131. client.reader = threading.Thread(
  132. target=self._reader, args=(client,), name="control-reader", daemon=True
  133. )
  134. client.writer.start()
  135. client.reader.start()
  136. def _writer(self, client: _Client) -> None:
  137. try:
  138. while not self._stop.is_set():
  139. try:
  140. line = client.queue.popleft()
  141. except IndexError:
  142. time.sleep(POLL_INTERVAL)
  143. continue
  144. client.conn.sendall(line)
  145. except OSError:
  146. pass
  147. finally:
  148. self._drop(client)
  149. def _reader(self, client: _Client) -> None:
  150. buffer = bytearray()
  151. try:
  152. while not self._stop.is_set():
  153. try:
  154. chunk = client.conn.recv(READ_CHUNK)
  155. except OSError:
  156. break
  157. if not chunk:
  158. break
  159. buffer.extend(chunk)
  160. while True:
  161. newline = buffer.find(b"\n")
  162. if newline == -1:
  163. break
  164. line = bytes(buffer[:newline])
  165. del buffer[: newline + 1]
  166. if line.strip():
  167. self._handle_line(client, line)
  168. except OSError:
  169. pass
  170. finally:
  171. self._drop(client)
  172. def _drop(self, client: _Client) -> None:
  173. last_viewer = False
  174. with self._lock:
  175. if client in self._clients:
  176. self._clients.remove(client)
  177. # Losing the last viewer ends an unattended chat. Decide under the
  178. # lock, then notify after releasing it: viewer_detached() emits,
  179. # and emitting re-enters this non-reentrant lock.
  180. last_viewer = client.viewer and not any(c.viewer for c in self._clients)
  181. self._shutdown(client)
  182. if last_viewer:
  183. self.service.viewer_detached()
  184. def _shutdown(self, client: _Client) -> None:
  185. try:
  186. client.conn.shutdown(socket.SHUT_RDWR)
  187. except OSError:
  188. pass
  189. try:
  190. client.conn.close()
  191. except OSError:
  192. pass
  193. # ------------------------------------------------------------------ dispatch
  194. def _handle_line(self, client: _Client, line: bytes) -> None:
  195. try:
  196. message = json.loads(line.decode("utf-8"))
  197. except (UnicodeDecodeError, json.JSONDecodeError):
  198. self._send(client, "error", code="bad_message", message="invalid JSON")
  199. return
  200. if not isinstance(message, dict) or "type" not in message:
  201. self._send(
  202. client, "error", code="bad_message", message="expected an object with a type"
  203. )
  204. return
  205. self._dispatch(client, message)
  206. def _dispatch(self, client: _Client, message: dict) -> None:
  207. command = message.get("type")
  208. service = self.service
  209. if command == "subscribe":
  210. client.viewer = bool(message.get("viewer", False))
  211. self._send_status(client)
  212. elif command == "status":
  213. self._send_status(client)
  214. elif command == "command_start":
  215. self._ack_or_error(client, command, service.arm(), "cannot start a command now")
  216. elif command == "command_cancel":
  217. self._ack_or_error(client, command, service.cancel(), "no command to cancel")
  218. elif command == "command_edit":
  219. ok = service.edit(str(message.get("text", "")))
  220. self._ack_or_error(client, command, ok, "no command to edit")
  221. elif command == "command_execute":
  222. self._execute(client, command)
  223. elif command == "chat_toggle":
  224. self._ack_or_error(client, command, service.toggle_chat(), "cannot toggle chat now")
  225. elif command == "chat_send":
  226. ok = service.submit_chat_text(str(message.get("text", "")))
  227. self._ack_or_error(client, command, ok, "cannot send chat now")
  228. elif command == "chat_capture":
  229. ok = service.set_chat_capture(bool(message.get("on", True)))
  230. self._ack_or_error(client, command, ok, "cannot set chat capture")
  231. elif command == "triage_chat":
  232. ok = service.set_triage_chat(bool(message.get("on", False)))
  233. self._ack_or_error(client, command, ok, "cannot set triage chat")
  234. elif command == "clear":
  235. self._ack_or_error(client, command, service.clear(), "nothing to clear")
  236. elif command == "speaker_list":
  237. self._send(client, "speaker_list", speakers=service.speaker_list())
  238. elif command == "speaker_name":
  239. speaker_id = str(message.get("speaker_id", ""))
  240. name = str(message.get("name", ""))
  241. if not name:
  242. self._send(client, "error", code="bad_message", message="name is required")
  243. elif service.speaker_name(speaker_id, name):
  244. self._send(client, "ack", command="speaker_name", ok=True)
  245. else:
  246. self._send(
  247. client,
  248. "error",
  249. code="unknown_speaker",
  250. message=f"unknown speaker: {speaker_id}",
  251. )
  252. else:
  253. self._send(client, "error", code="unknown_command", message=f"unknown: {command}")
  254. def _execute(self, client: _Client, command: str) -> None:
  255. if self.cfg.assistant.require_viewer and self.viewer_count() == 0:
  256. self._send(client, "error", code="no_viewer", message="no viewer attached")
  257. return
  258. result = self.service.execute()
  259. if result is EXECUTE_BUSY:
  260. self._send(client, "error", code="busy", message="inference queue full")
  261. return
  262. if result is None:
  263. self._send(client, "error", code="not_ready", message="command is not ready")
  264. return
  265. self._send(client, "ack", command=command, ok=True)
  266. def _ack_or_error(self, client: _Client, command: str, ok: bool, reason: str) -> None:
  267. if ok:
  268. self._send(client, "ack", command=command, ok=True)
  269. else:
  270. self._send(client, "error", code="refused", message=reason)
  271. def _send_status(self, client: _Client) -> None:
  272. self._send(client, "status", **self.service.status())