nvda2cthulhu server plugin added. This can replace the nvda2speechd server if desired.

This commit is contained in:
Storm Dragon
2026-01-04 18:46:56 -05:00
parent 1f6a2a06e5
commit 89573b7544
7 changed files with 358 additions and 0 deletions

View File

@@ -6,6 +6,7 @@ subdir('DisplayVersion')
subdir('HelloCthulhu')
subdir('GameMode')
subdir('IndentationAudio')
subdir('nvda2cthulhu')
subdir('OCR')
subdir('PluginManager')
subdir('SpeechHistory')

View File

@@ -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.

View File

@@ -0,0 +1 @@
"""NVDA to Cthulhu bridge plugin."""

View File

@@ -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'
)

View File

@@ -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

View File

@@ -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