nvda2cthulhu server plugin added. This can replace the nvda2speechd server if desired.
This commit is contained in:
@@ -65,6 +65,10 @@ optdepends=(
|
|||||||
'python-webcolors: Color name lookup for OCR text decoration'
|
'python-webcolors: Color name lookup for OCR text decoration'
|
||||||
'tesseract: OCR engine for text recognition'
|
'tesseract: OCR engine for text recognition'
|
||||||
'tesseract-data-eng: English language data for Tesseract'
|
'tesseract-data-eng: English language data for Tesseract'
|
||||||
|
|
||||||
|
# nvda2cthulhu plugin (optional)
|
||||||
|
'python-msgpack: Msgpack decoding for nvda2cthulhu'
|
||||||
|
'python-tornado: WebSocket server for nvda2cthulhu'
|
||||||
)
|
)
|
||||||
makedepends=(
|
makedepends=(
|
||||||
git
|
git
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ subdir('DisplayVersion')
|
|||||||
subdir('HelloCthulhu')
|
subdir('HelloCthulhu')
|
||||||
subdir('GameMode')
|
subdir('GameMode')
|
||||||
subdir('IndentationAudio')
|
subdir('IndentationAudio')
|
||||||
|
subdir('nvda2cthulhu')
|
||||||
subdir('OCR')
|
subdir('OCR')
|
||||||
subdir('PluginManager')
|
subdir('PluginManager')
|
||||||
subdir('SpeechHistory')
|
subdir('SpeechHistory')
|
||||||
|
|||||||
21
src/cthulhu/plugins/nvda2cthulhu/README.md
Normal file
21
src/cthulhu/plugins/nvda2cthulhu/README.md
Normal 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.
|
||||||
1
src/cthulhu/plugins/nvda2cthulhu/__init__.py
Normal file
1
src/cthulhu/plugins/nvda2cthulhu/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""NVDA to Cthulhu bridge plugin."""
|
||||||
14
src/cthulhu/plugins/nvda2cthulhu/meson.build
Normal file
14
src/cthulhu/plugins/nvda2cthulhu/meson.build
Normal 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'
|
||||||
|
)
|
||||||
8
src/cthulhu/plugins/nvda2cthulhu/plugin.info
Normal file
8
src/cthulhu/plugins/nvda2cthulhu/plugin.info
Normal 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
|
||||||
309
src/cthulhu/plugins/nvda2cthulhu/plugin.py
Normal file
309
src/cthulhu/plugins/nvda2cthulhu/plugin.py
Normal 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
|
||||||
Reference in New Issue
Block a user