diff --git a/src/cthulhu/plugins/SSIPProxy/__init__.py b/src/cthulhu/plugins/SSIPProxy/__init__.py new file mode 100644 index 0000000..f099bac --- /dev/null +++ b/src/cthulhu/plugins/SSIPProxy/__init__.py @@ -0,0 +1,2 @@ +# SSIPProxy plugin - SSIP protocol proxy for Cthulhu +from .plugin import * diff --git a/src/cthulhu/plugins/SSIPProxy/meson.build b/src/cthulhu/plugins/SSIPProxy/meson.build new file mode 100644 index 0000000..0c5f8b7 --- /dev/null +++ b/src/cthulhu/plugins/SSIPProxy/meson.build @@ -0,0 +1,14 @@ +ssipproxy_python_sources = files([ + '__init__.py', + 'plugin.py' +]) + +python3.install_sources( + ssipproxy_python_sources, + subdir: 'cthulhu/plugins/SSIPProxy' +) + +install_data( + 'plugin.info', + install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'SSIPProxy' +) diff --git a/src/cthulhu/plugins/SSIPProxy/plugin.info b/src/cthulhu/plugins/SSIPProxy/plugin.info new file mode 100644 index 0000000..9c2d297 --- /dev/null +++ b/src/cthulhu/plugins/SSIPProxy/plugin.info @@ -0,0 +1,11 @@ +[Plugin] +name=SSIP Proxy +module_name=SSIPProxy +version=1.0.0 +description=SSIP protocol proxy for speech-dispatcher TCP clients. Allows applications like Say the Spire (Slay the Spire accessibility mod) to have their speech output go through Cthulhu for both speech and braille support. +authors=Stormux +copyright=Copyright (c) 2024-2025 Stormux +website=https://stormux.org +icon_name=audio-speakers +builtin=false +hidden=false diff --git a/src/cthulhu/plugins/SSIPProxy/plugin.py b/src/cthulhu/plugins/SSIPProxy/plugin.py new file mode 100644 index 0000000..ea79179 --- /dev/null +++ b/src/cthulhu/plugins/SSIPProxy/plugin.py @@ -0,0 +1,587 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2024-2025 Stormux +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. +# + +"""SSIP Proxy plugin for Cthulhu screen reader. + +This plugin acts as a speech-dispatcher SSIP server, allowing applications +that use speech-dispatcher's TCP interface (like Say the Spire for Slay the Spire) +to have their speech output go through Cthulhu for both speech and braille support. +""" + +import logging +import select +import socket +import threading +from threading import Thread, Lock +from cthulhu.plugin import Plugin, cthulhu_hookimpl +from cthulhu import debug + +logger = logging.getLogger(__name__) + +# Default speech-dispatcher TCP port +SSIP_PORT = 6560 + +# SSIP Response codes +RESP_OK = "200 OK" +RESP_CLIENT_NAME_SET = "208 OK CLIENT NAME SET" +RESP_RECEIVING_DATA = "230 OK RECEIVING DATA" +RESP_MSG_QUEUED = "225 OK MESSAGE QUEUED" +RESP_BYE = "231 HAPPY HACKING" +RESP_ERR_INVALID = "300 ERR INVALID COMMAND" + + +class SSIPConnection: + """Handles a single SSIP client connection.""" + + def __init__(self, clientSocket, plugin): + """Initialize the connection handler. + + Args: + clientSocket: The client socket connection + plugin: Reference to the parent plugin + """ + self.socket = clientSocket + self.plugin = plugin + self.clientName = None + self.receivingData = False + self.dataBuffer = [] + self.msgId = 0 + + def send(self, message): + """Send a response to the client. + + Args: + message: The response message to send + """ + try: + response = message + "\r\n" + self.socket.sendall(response.encode("utf-8")) + except Exception as e: + debug.printMessage(debug.LEVEL_WARNING, f"SSIP send error: {e}", True) + + def sendMessageQueued(self, msgId): + """Send a message queued response with message ID. + + Sends both lines in a single write to avoid race conditions. + + Args: + msgId: The message ID to include + """ + self.sendMultiLine(f"225-{msgId}", "225 OK MESSAGE QUEUED") + + def sendMultiLine(self, line1, line2): + """Send a two-line response in a single write. + + This avoids race conditions where the client reads between lines. + + Args: + line1: First line of response + line2: Second line of response + """ + try: + response = f"{line1}\r\n{line2}\r\n" + self.socket.sendall(response.encode("utf-8")) + except Exception as e: + debug.printMessage(debug.LEVEL_WARNING, f"SSIP send error: {e}", True) + + def handleLine(self, line): + """Process a single line from the client. + + Args: + line: The line to process + + Returns: + True to continue, False to close connection + """ + line = line.strip() + if not line: + return True + + logger.debug(f"SSIP <<< {line}") + + # If we're receiving SPEAK data + if self.receivingData: + return self.handleDataLine(line) + + # Parse command + parts = line.split(None, 1) + if not parts: + return True + + cmd = parts[0].upper() + args = parts[1] if len(parts) > 1 else "" + + if cmd == "SET": + return self.handleSet(args) + elif cmd == "SPEAK": + return self.handleSpeak() + elif cmd == "STOP": + return self.handleStop(args) + elif cmd == "CANCEL": + return self.handleCancel(args) + elif cmd == "PAUSE": + self.send(RESP_OK) + return True + elif cmd == "RESUME": + self.send(RESP_OK) + return True + elif cmd == "QUIT": + self.send(RESP_BYE) + return False + elif cmd == "HELP": + self.send("299-SSIP Proxy for Cthulhu") + self.send("299 OK HELP SENT") + return True + elif cmd == "GET": + return self.handleGet(args) + elif cmd == "LIST": + return self.handleList(args) + elif cmd == "HISTORY": + return self.handleHistory(args) + elif cmd == "CHAR": + # Character speaking - extract and speak the character + if args: + char = args.strip() + if char.lower() == "space": + char = " " + self.plugin.outputMessage(char) + self.msgId += 1 + self.sendMessageQueued(self.msgId) + return True + elif cmd == "KEY": + # Key speaking - speak the key name + if args: + self.plugin.outputMessage(args.strip()) + self.msgId += 1 + self.sendMessageQueued(self.msgId) + return True + elif cmd == "SOUND_ICON": + # Sound icon - acknowledge but ignore + self.msgId += 1 + self.sendMessageQueued(self.msgId) + return True + else: + logger.debug(f"Unknown SSIP command: {cmd}") + self.send(RESP_OK) + return True + + def handleGet(self, args): + """Handle GET command. + + Args: + args: Command arguments + + Returns: + True to continue + """ + param = args.strip().upper() if args else "" + + if param == "VERSION": + self.send("240-speechd-ssip-proxy 0.1") + self.send("240 OK VERSION SENT") + elif param == "OUTPUT_MODULES": + self.send("250-cthulhu") + self.send("250 OK MODULES LIST SENT") + elif param == "VOICES": + self.send("251-cthulhu\tdefault\tnone") + self.send("251 OK VOICES LIST SENT") + elif param == "RATE" or param == "PITCH" or param == "VOLUME": + self.send("251-0") + self.send("251 OK GET RETURNED") + else: + logger.debug(f"Unknown GET parameter: {param}") + self.send("260-unknown") + self.send("260 OK GET RETURNED") + + return True + + def handleList(self, args): + """Handle LIST command. + + Args: + args: Command arguments + + Returns: + True to continue + """ + param = args.strip().upper() if args else "" + + if param == "OUTPUT_MODULES": + self.send("250-cthulhu") + self.send("250 OK MODULES LIST SENT") + elif param == "VOICES": + self.send("251-cthulhu\tdefault\tnone") + self.send("251 OK VOICE LIST SENT") + else: + self.send("250 OK LIST SENT") + + return True + + def handleHistory(self, args): + """Handle HISTORY command. + + Args: + args: Command arguments + + Returns: + True to continue + """ + parts = args.split() if args else [] + + if len(parts) >= 2 and parts[0].upper() == "GET": + subCmd = parts[1].upper() + if subCmd == "CLIENT_ID": + # Return a client ID - send both lines in single write + self.sendMultiLine("240-1", "240 OK CLIENT ID SENT") + elif subCmd == "MESSAGE_ID": + self.sendMultiLine(f"240-{self.msgId}", "240 OK MESSAGE ID SENT") + else: + self.sendMultiLine("240-0", "240 OK HISTORY GET") + elif len(parts) >= 1 and parts[0].upper() == "CURSOR": + self.send("200 OK CURSOR SET") + elif len(parts) >= 1 and parts[0].upper() == "SAY": + # History say - acknowledge + self.msgId += 1 + self.sendMessageQueued(self.msgId) + else: + self.send("200 OK HISTORY") + + return True + + def handleSet(self, args): + """Handle SET command. + + Args: + args: Command arguments + + Returns: + True to continue + """ + parts = args.split(None, 2) + if len(parts) < 2: + self.send(RESP_OK) + return True + + scope = parts[0].lower() + param = parts[1].upper() + value = parts[2] if len(parts) > 2 else "" + + if param == "CLIENT_NAME": + self.clientName = value + logger.info(f"SSIP client connected: {value}") + self.send(RESP_CLIENT_NAME_SET) + elif param == "PRIORITY": + self.send(f"200 OK PRIORITY SET") + elif param == "RATE": + self.send(f"200 OK RATE SET") + elif param == "PITCH": + self.send(f"200 OK PITCH SET") + elif param == "VOLUME": + self.send(f"200 OK VOLUME SET") + elif param == "VOICE_TYPE": + self.send(f"200 OK VOICE SET") + elif param == "LANGUAGE": + self.send(f"200 OK LANGUAGE SET") + elif param == "SSML_MODE": + self.send(f"200 OK SSML MODE SET") + elif param == "PUNCTUATION": + self.send(f"200 OK PUNCTUATION SET") + elif param == "SPELLING": + self.send(f"200 OK SPELLING SET") + elif param == "CAP_LET_RECOGN": + self.send(f"200 OK CAP LET RECOGNITION SET") + elif param == "OUTPUT_MODULE": + self.send(f"200 OK OUTPUT MODULE SET") + else: + logger.debug(f"Unknown SET parameter: {param}") + self.send(RESP_OK) + + return True + + def handleSpeak(self): + """Handle SPEAK command. + + Returns: + True to continue + """ + self.receivingData = True + self.dataBuffer = [] + self.send(RESP_RECEIVING_DATA) + return True + + def handleDataLine(self, line): + """Handle a line of SPEAK data. + + Args: + line: The data line + + Returns: + True to continue + """ + # Check for end-of-data marker (single dot) + if line == ".": + # End of data - process the message + self.receivingData = False + text = "\n".join(self.dataBuffer) + self.dataBuffer = [] + + # Send message ID and queued response together + self.msgId += 1 + self.sendMessageQueued(self.msgId) + + # Forward to Cthulhu + if text.strip(): + self.plugin.outputMessage(text.strip()) + + return True + + # Handle dot-escaping (lines starting with . have extra dot) + if line.startswith(".."): + line = line[1:] + + self.dataBuffer.append(line) + return True + + def handleStop(self, args): + """Handle STOP command. + + Args: + args: Command arguments + + Returns: + True to continue + """ + # Don't explicitly stop - let speech.speak(interrupt=True) handle it + # Calling stop() followed by speak() can cause race conditions + # where speech-dispatcher drops the new message + self.send(RESP_OK) + return True + + def handleCancel(self, args): + """Handle CANCEL command. + + Args: + args: Command arguments + + Returns: + True to continue + """ + self.send(RESP_OK) + return True + + +class SSIPProxy(Plugin): + """Plugin that provides an SSIP server interface for external applications.""" + + def __init__(self): + """Initialize the plugin.""" + super().__init__() + self.lock = Lock() + self.active = False + self.serverThread = None + self.serverSocket = None + self.clientSockets = [] # Track active client connections + self.clientLock = Lock() # Lock for client list + + @cthulhu_hookimpl + def activate(self): + """Activate the SSIP proxy plugin.""" + super().activate() + logger.info("Activating SSIP Proxy Plugin") + self.activateServer() + + @cthulhu_hookimpl + def deactivate(self): + """Deactivate the SSIP proxy plugin.""" + logger.info("Deactivating SSIP Proxy Plugin") + self.deactivateServer() + super().deactivate() + + def activateServer(self): + """Start the SSIP server thread.""" + with self.lock: + self.active = True + + # Only start if not already running + if self.serverThread is None or not self.serverThread.is_alive(): + self.serverThread = Thread(target=self.serverWorker) + self.serverThread.daemon = True + self.serverThread.start() + + def deactivateServer(self): + """Stop the SSIP server thread.""" + with self.lock: + self.active = False + + # Close all client connections first + with self.clientLock: + for clientSocket in self.clientSockets: + try: + clientSocket.close() + except Exception: + pass + self.clientSockets.clear() + + # Close the server socket to interrupt accept() + if self.serverSocket: + try: + self.serverSocket.close() + except Exception: + pass + + # Try to join the thread if it's alive, with a timeout + if self.serverThread and self.serverThread.is_alive(): + try: + self.serverThread.join(timeout=2.0) + except Exception as e: + logger.warning(f"Error stopping server thread: {e}") + + def isActive(self): + """Check if the server is active.""" + with self.lock: + return self.active + + def outputMessage(self, message): + """Output a message through Cthulhu's speech and braille systems. + + Args: + message: The message to output + """ + try: + # Use speech.speak directly for more reliable rapid-fire messages + from cthulhu import speech + speech.speak(message, interrupt=True) + + # Also update braille if possible + try: + scriptManager = self.app.getDynamicApiManager().getAPI('ScriptManager') + manager = scriptManager.get_manager() + script = manager.get_default_script() + if script: + # Flash message to braille + from cthulhu import braille + braille.displayMessage(message) + except Exception: + pass # Braille is optional + except Exception as e: + debug.printMessage(debug.LEVEL_WARNING, + f"SSIP output error: {e} for: {message}", True) + + def serverWorker(self): + """Worker thread that runs the SSIP server.""" + try: + # Create TCP socket + self.serverSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.serverSocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + try: + self.serverSocket.bind(("127.0.0.1", SSIP_PORT)) + except OSError as e: + logger.error(f"Cannot bind to port {SSIP_PORT}: {e}") + logger.error("Another process may be using this port.") + return + + self.serverSocket.listen(5) + debug.printMessage(debug.LEVEL_INFO, + f"SSIP Proxy listening on 127.0.0.1:{SSIP_PORT}", True) + + while self.isActive(): + # Check for new connections with timeout + try: + ready, _, _ = select.select([self.serverSocket], [], [], 0.8) + except (select.error, ValueError): + break + + if not ready: + continue + + try: + clientSocket, addr = self.serverSocket.accept() + debug.printMessage(debug.LEVEL_INFO, + f"SSIP client connected from {addr}", True) + + # Handle this client in a separate thread + clientThread = Thread( + target=self.handleClient, + args=(clientSocket,) + ) + clientThread.daemon = True + clientThread.start() + except Exception as e: + if self.isActive(): + logger.error(f"Error accepting connection: {e}") + + except Exception as e: + logger.error(f"SSIP server error: {e}") + finally: + if self.serverSocket: + try: + self.serverSocket.close() + except Exception: + pass + logger.info("SSIP Proxy server stopped") + + def handleClient(self, clientSocket): + """Handle a client connection. + + Args: + clientSocket: The client socket + """ + # Track this client socket + with self.clientLock: + self.clientSockets.append(clientSocket) + + # Disable Nagle's algorithm for immediate sending + clientSocket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + clientSocket.settimeout(30.0) + connection = SSIPConnection(clientSocket, self) + buffer = "" + + try: + while self.isActive(): + try: + data = clientSocket.recv(4096) + if not data: + break + + buffer += data.decode("utf-8") + + # Process complete lines + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.rstrip("\r") + if not connection.handleLine(line): + return + except socket.timeout: + continue + except Exception as e: + logger.debug(f"Client read error: {e}") + break + finally: + # Remove from tracking list + with self.clientLock: + if clientSocket in self.clientSockets: + self.clientSockets.remove(clientSocket) + + try: + clientSocket.close() + except Exception: + pass + debug.printMessage(debug.LEVEL_INFO, "SSIP client disconnected", True) diff --git a/src/cthulhu/plugins/meson.build b/src/cthulhu/plugins/meson.build index 0ce7488..31a0684 100644 --- a/src/cthulhu/plugins/meson.build +++ b/src/cthulhu/plugins/meson.build @@ -13,3 +13,4 @@ subdir('SpeechHistory') subdir('SimplePluginSystem') subdir('hello_world') subdir('self_voice') +subdir('SSIPProxy')