nvda2cthulhu server plugin added. This can replace the nvda2speechd server if desired.
This commit is contained in:
@@ -6,6 +6,7 @@ subdir('DisplayVersion')
|
||||
subdir('HelloCthulhu')
|
||||
subdir('GameMode')
|
||||
subdir('IndentationAudio')
|
||||
subdir('nvda2cthulhu')
|
||||
subdir('OCR')
|
||||
subdir('PluginManager')
|
||||
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