From 89573b754418563f34b12b9e6fa72da07bee09b9 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 4 Jan 2026 18:46:56 -0500 Subject: [PATCH] nvda2cthulhu server plugin added. This can replace the nvda2speechd server if desired. --- distro-packages/Arch-Linux/PKGBUILD | 4 + src/cthulhu/plugins/meson.build | 1 + src/cthulhu/plugins/nvda2cthulhu/README.md | 21 ++ src/cthulhu/plugins/nvda2cthulhu/__init__.py | 1 + src/cthulhu/plugins/nvda2cthulhu/meson.build | 14 + src/cthulhu/plugins/nvda2cthulhu/plugin.info | 8 + src/cthulhu/plugins/nvda2cthulhu/plugin.py | 309 +++++++++++++++++++ 7 files changed, 358 insertions(+) create mode 100644 src/cthulhu/plugins/nvda2cthulhu/README.md create mode 100644 src/cthulhu/plugins/nvda2cthulhu/__init__.py create mode 100644 src/cthulhu/plugins/nvda2cthulhu/meson.build create mode 100644 src/cthulhu/plugins/nvda2cthulhu/plugin.info create mode 100644 src/cthulhu/plugins/nvda2cthulhu/plugin.py diff --git a/distro-packages/Arch-Linux/PKGBUILD b/distro-packages/Arch-Linux/PKGBUILD index 74dd5e7..94e25b7 100644 --- a/distro-packages/Arch-Linux/PKGBUILD +++ b/distro-packages/Arch-Linux/PKGBUILD @@ -65,6 +65,10 @@ optdepends=( 'python-webcolors: Color name lookup for OCR text decoration' 'tesseract: OCR engine for text recognition' 'tesseract-data-eng: English language data for Tesseract' + + # nvda2cthulhu plugin (optional) + 'python-msgpack: Msgpack decoding for nvda2cthulhu' + 'python-tornado: WebSocket server for nvda2cthulhu' ) makedepends=( git diff --git a/src/cthulhu/plugins/meson.build b/src/cthulhu/plugins/meson.build index 707506c..0ce7488 100644 --- a/src/cthulhu/plugins/meson.build +++ b/src/cthulhu/plugins/meson.build @@ -6,6 +6,7 @@ subdir('DisplayVersion') subdir('HelloCthulhu') subdir('GameMode') subdir('IndentationAudio') +subdir('nvda2cthulhu') subdir('OCR') subdir('PluginManager') subdir('SpeechHistory') diff --git a/src/cthulhu/plugins/nvda2cthulhu/README.md b/src/cthulhu/plugins/nvda2cthulhu/README.md new file mode 100644 index 0000000..27f0e4f --- /dev/null +++ b/src/cthulhu/plugins/nvda2cthulhu/README.md @@ -0,0 +1,21 @@ +# NVDA to Cthulhu plugin + +This plugin starts a local WebSocket server that accepts nvda2cthulhu messages +and speaks/brailles them through Cthulhu. + +## Dependencies + +These are optional and only needed when the plugin is enabled: + +- python-msgpack +- python-tornado + +If they are missing, the plugin will announce that dependencies are missing and +will not start the server. + +## Usage + +- Enable the plugin in Cthulhu's Plugin Manager. +- The server listens on 127.0.0.1 and uses the port from NVDA2SPEECHD_HOST + if set; otherwise it defaults to 3457. +- Toggle interrupt/no-interrupt mode with cthulhu+shift+n. diff --git a/src/cthulhu/plugins/nvda2cthulhu/__init__.py b/src/cthulhu/plugins/nvda2cthulhu/__init__.py new file mode 100644 index 0000000..778b2a3 --- /dev/null +++ b/src/cthulhu/plugins/nvda2cthulhu/__init__.py @@ -0,0 +1 @@ +"""NVDA to Cthulhu bridge plugin.""" diff --git a/src/cthulhu/plugins/nvda2cthulhu/meson.build b/src/cthulhu/plugins/nvda2cthulhu/meson.build new file mode 100644 index 0000000..56a553c --- /dev/null +++ b/src/cthulhu/plugins/nvda2cthulhu/meson.build @@ -0,0 +1,14 @@ +nvda2cthulhu_python_sources = files([ + '__init__.py', + 'plugin.py' +]) + +python3.install_sources( + nvda2cthulhu_python_sources, + subdir: 'cthulhu/plugins/nvda2cthulhu' +) + +install_data( + 'plugin.info', + install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'nvda2cthulhu' +) diff --git a/src/cthulhu/plugins/nvda2cthulhu/plugin.info b/src/cthulhu/plugins/nvda2cthulhu/plugin.info new file mode 100644 index 0000000..e00d8a4 --- /dev/null +++ b/src/cthulhu/plugins/nvda2cthulhu/plugin.info @@ -0,0 +1,8 @@ +name = NVDA to Cthulhu +version = 1.0.0 +description = WebSocket listener that accepts nvda2cthulhu messages and speaks them through Cthulhu +authors = Storm Dragon storm_dragon@stormux.org +website = https://stormux.org +copyright = Copyright 2026 +builtin = false +hidden = false diff --git a/src/cthulhu/plugins/nvda2cthulhu/plugin.py b/src/cthulhu/plugins/nvda2cthulhu/plugin.py new file mode 100644 index 0000000..3bb74c5 --- /dev/null +++ b/src/cthulhu/plugins/nvda2cthulhu/plugin.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2026 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. +# + +"""NVDA to Cthulhu bridge plugin.""" + +import asyncio +import logging +import os +import threading +import urllib.parse + +try: + import msgpack +except Exception: # pragma: no cover - optional dependency + msgpack = None + +try: + import tornado + import tornado.httpserver + import tornado.ioloop + import tornado.web + import tornado.websocket + from tornado.platform.asyncio import AsyncIOMainLoop +except Exception: # pragma: no cover - optional dependency + tornado = None + +from cthulhu.plugin import Plugin, cthulhu_hookimpl +from cthulhu import braille +from cthulhu import speech +from cthulhu import settings_manager + +logger = logging.getLogger(__name__) + +DEFAULT_PORT = 3457 + + +def _coerce_text(value): + if value is None: + return '' + if isinstance(value, bytes): + try: + return value.decode('utf-8') + except Exception: + return value.decode('utf-8', errors='replace') + return str(value) + + +if tornado is None: + class Nvda2CthulhuSocket: # pragma: no cover - optional dependency + """Placeholder when tornado is not available.""" + pass +else: + class Nvda2CthulhuSocket(tornado.websocket.WebSocketHandler): + """WebSocket handler for NVDA to Cthulhu messages.""" + + def initialize(self, plugin): + self.plugin = plugin + + def check_origin(self, origin): + return True + + def on_message(self, message): + self.plugin.handle_message(message) + + +class Nvda2Cthulhu(Plugin): + """Bridge server that accepts NVDA to Cthulhu messages.""" + + def __init__(self): + super().__init__() + self.settingsManager = settings_manager.getManager() + self.interruptEnabled = True + self.serverThread = None + self.serverLock = threading.Lock() + self.httpServer = None + self.ioLoop = None + self.asyncioLoop = None + + @cthulhu_hookimpl + def activate(self, plugin=None): + if plugin is not None and plugin is not self: + return + + logger.info("Activating NVDA to Cthulhu plugin") + self.registerGestureByString( + self.toggle_interrupt, + "NVDA to Cthulhu interrupt mode", + "kb:cthulhu+shift+n", + learnModeEnabled=True + ) + if not self._dependencies_available(): + self._present_message("NVDA to Cthulhu missing dependencies: python-msgpack and python-tornado") + logger.warning("NVDA to Cthulhu dependencies missing: msgpack or tornado") + return + self.start_server() + + @cthulhu_hookimpl + def deactivate(self, plugin=None): + if plugin is not None and plugin is not self: + return + + logger.info("Deactivating NVDA to Cthulhu plugin") + self.stop_server() + + def start_server(self): + with self.serverLock: + if self.serverThread and self.serverThread.is_alive(): + return + self.serverThread = threading.Thread( + target=self._server_main, + name="Nvda2CthulhuServer", + daemon=True + ) + self.serverThread.start() + + def stop_server(self): + with self.serverLock: + ioLoop = self.ioLoop + httpServer = self.httpServer + + if ioLoop is None: + return + + def _stop(): + if httpServer: + httpServer.stop() + ioLoop.stop() + + try: + ioLoop.add_callback(_stop) + except Exception as exc: + logger.warning(f"NVDA to Cthulhu: failed to stop server cleanly: {exc}") + + if self.serverThread and self.serverThread.is_alive(): + try: + self.serverThread.join(timeout=2.0) + except Exception as exc: + logger.warning(f"NVDA to Cthulhu: failed to join server thread: {exc}") + + def toggle_interrupt(self, script=None, inputEvent=None): + self.interruptEnabled = not self.interruptEnabled + mode = "interrupt" if self.interruptEnabled else "no interrupt" + self._present_message(f"NVDA to Cthulhu {mode}") + return True + + def handle_message(self, message): + request = self._parse_request(message) + if not request: + return + + requestType, payload = request + if requestType == "SpeakText": + self._handle_speak(payload) + elif requestType == "BrailleText": + self._handle_braille(payload) + elif requestType == "CancelSpeech": + speech.stop() + + def _server_main(self): + if not self._dependencies_available(): + return + address, port = self._get_bind_address() + try: + self.asyncioLoop = asyncio.new_event_loop() + asyncio.set_event_loop(self.asyncioLoop) + AsyncIOMainLoop().install() + + app = tornado.web.Application([ + (r"/", Nvda2CthulhuSocket, {"plugin": self}), + ]) + + self.httpServer = tornado.httpserver.HTTPServer(app) + self.httpServer.listen(port, address=address) + self.ioLoop = tornado.ioloop.IOLoop.current() + logger.info(f"NVDA to Cthulhu listening on {address}:{port}") + self.ioLoop.start() + except Exception as exc: + logger.error(f"NVDA to Cthulhu failed to start server: {exc}") + self._present_message("NVDA to Cthulhu server failed to start") + finally: + self.httpServer = None + if self.ioLoop: + try: + self.ioLoop.stop() + except Exception: + pass + self.ioLoop = None + self.asyncioLoop = None + + def _get_bind_address(self): + port = DEFAULT_PORT + host = os.environ.get("NVDA2SPEECHD_HOST") + if host: + try: + parsed = urllib.parse.urlparse(host) + if parsed.port: + port = parsed.port + except Exception: + logger.warning(f"NVDA to Cthulhu: invalid NVDA2SPEECHD_HOST: {host}") + return "127.0.0.1", port + + def _parse_request(self, message): + if not self._dependencies_available(): + return None + if isinstance(message, str): + return "SpeakText", message + + if not isinstance(message, (bytes, bytearray)): + return None + + try: + payload = msgpack.unpackb(message, raw=False) + except Exception as exc: + logger.warning(f"NVDA to Cthulhu: failed to decode msgpack: {exc}") + return None + + return self._parse_payload(payload) + + def _parse_payload(self, payload): + if isinstance(payload, str): + if payload == "CancelSpeech": + return "CancelSpeech", None + return "SpeakText", payload + + if isinstance(payload, dict): + if len(payload) != 1: + return None + key, value = next(iter(payload.items())) + key = _coerce_text(key) + if key == "SpeakText": + return "SpeakText", _coerce_text(value) + if key == "BrailleText": + return "BrailleText", _coerce_text(value) + if key == "CancelSpeech": + return "CancelSpeech", None + return None + + if isinstance(payload, (list, tuple)): + if not payload: + return None + key = _coerce_text(payload[0]) + if key == "CancelSpeech": + return "CancelSpeech", None + if len(payload) < 2: + return None + value = _coerce_text(payload[1]) + if key == "SpeakText": + return "SpeakText", value + if key == "BrailleText": + return "BrailleText", value + return None + + return None + + def _handle_speak(self, text): + if not text or not text.strip(): + return + speech.speak(text, interrupt=self.interruptEnabled) + + def _handle_braille(self, text): + if not text or not text.strip(): + return + if not self._braille_enabled(): + return + duration = self._get_braille_flash_time() + braille.displayMessage(text, flashTime=duration) + + def _braille_enabled(self): + if not self.settingsManager: + return False + enableBraille = self.settingsManager.getSetting('enableBraille') + enableMonitor = self.settingsManager.getSetting('enableBrailleMonitor') + enableFlash = self.settingsManager.getSetting('enableFlashMessages') + return bool((enableBraille or enableMonitor) and enableFlash) + + def _get_braille_flash_time(self): + if not self.settingsManager: + return 0 + if self.settingsManager.getSetting('flashIsPersistent'): + return -1 + return self.settingsManager.getSetting('brailleFlashTime') + + def _present_message(self, message): + try: + scriptManagerApi = self.app.getDynamicApiManager().getAPI('ScriptManager') + scriptManager = scriptManagerApi.get_manager() + scriptManager.get_default_script().presentMessage(message, resetStyles=False) + except Exception: + logger.info(message) + + def _dependencies_available(self): + return msgpack is not None and tornado is not None