mirror of https://gitlab.com/openlp/openlp.git
278 lines
11 KiB
Python
278 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
##########################################################################
|
|
# OpenLP - Open Source Lyrics Projection #
|
|
# ---------------------------------------------------------------------- #
|
|
# Copyright (c) 2008-2024 OpenLP Developers #
|
|
# ---------------------------------------------------------------------- #
|
|
# This program is free software: you can redistribute it and/or modify #
|
|
# it under the terms of the GNU General Public License as published by #
|
|
# the Free Software Foundation, either version 3 of the License, or #
|
|
# (at your option) any later version. #
|
|
# #
|
|
# This program 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 General Public License for more details. #
|
|
# #
|
|
# You should have received a copy of the GNU General Public License #
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
|
|
##########################################################################
|
|
"""
|
|
The :mod:`websockets` module contains the websockets server. This is a server used by remotes to listen for stage
|
|
changes from within OpenLP. It uses JSON to communicate with the remotes.
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from dataclasses import asdict, dataclass
|
|
from typing import Optional, Union
|
|
|
|
import time
|
|
from PyQt5 import QtCore
|
|
from websockets import serve
|
|
|
|
from openlp.core.common.mixins import LogMixin, RegistryProperties
|
|
from openlp.core.common.registry import Registry, RegistryBase
|
|
from openlp.core.threading import ThreadWorker, run_thread
|
|
from openlp.core.api.websocketspoll import WebSocketPoller
|
|
|
|
USERS = set()
|
|
poller = WebSocketPoller()
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
# Disable DEBUG logs for the websockets lib
|
|
ws_logger = logging.getLogger('websockets')
|
|
ws_logger.setLevel(logging.ERROR)
|
|
|
|
|
|
@dataclass
|
|
class WebSocketMessage:
|
|
plugin: Optional[str]
|
|
key: str
|
|
value: Union[int, str, dict, list]
|
|
|
|
|
|
class WebSocketWorker(ThreadWorker, RegistryProperties, LogMixin):
|
|
"""
|
|
A special Qt thread class to allow the WebSockets server to run at the same time as the UI.
|
|
|
|
There's now two queues for websockets: one that handle queues for state changes, and another for messages.
|
|
The main idea is that state-based websocket connections will be removed in future, in favour of message-based
|
|
connections (this will also help save some bandwidth on busy networks)
|
|
"""
|
|
def start(self):
|
|
"""
|
|
Run the worker.
|
|
"""
|
|
settings = Registry().get('settings_thread')
|
|
address = settings.value('api/ip address')
|
|
port = settings.value('api/websocket port')
|
|
# Start the event loop
|
|
self.event_loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(self.event_loop)
|
|
# Create the websocker server
|
|
loop = 1
|
|
self.server = None
|
|
self.state_queues = set()
|
|
self.message_queues = set()
|
|
while not self.server:
|
|
try:
|
|
self.server = serve(self.handle_websocket, address, port)
|
|
log.debug('WebSocket server started on {addr}:{port}'.format(addr=address, port=port))
|
|
except Exception:
|
|
log.exception('Failed to start WebSocket server')
|
|
time.sleep(0.1)
|
|
if not self.server and loop > 3:
|
|
log.error('Unable to start WebSocket server {addr}:{port}, giving up'.format(addr=address, port=port))
|
|
break
|
|
loop += 1
|
|
if self.server:
|
|
# If the websocket server exists, start listening
|
|
try:
|
|
self.event_loop.run_until_complete(self.server)
|
|
self.event_loop.run_forever()
|
|
except Exception:
|
|
log.exception('Failed to start WebSocket server')
|
|
finally:
|
|
self.event_loop.close()
|
|
self.quit.emit()
|
|
|
|
def stop(self):
|
|
"""
|
|
Stop the websocket server
|
|
"""
|
|
try:
|
|
self.event_loop.call_soon_threadsafe(self.event_loop.stop)
|
|
except BaseException:
|
|
pass
|
|
|
|
async def handle_websocket(self, websocket, path: str):
|
|
"""
|
|
Handle web socket requests and return the state information
|
|
Waits for information to come over queue
|
|
|
|
:param websocket: request from client
|
|
:param path: determines the endpoints supported
|
|
"""
|
|
client_id = uuid.uuid4() if log.getEffectiveLevel() == logging.DEBUG else 0
|
|
log.debug(f'(client_id={client_id}) WebSocket handle_websocket connection')
|
|
queue = asyncio.Queue()
|
|
is_state_queue = not path.startswith('/messages')
|
|
await self.register(websocket, client_id, queue, is_state_queue)
|
|
try:
|
|
if is_state_queue:
|
|
reply = poller.get_state()
|
|
if reply:
|
|
await self.send_reply(websocket, client_id, reply)
|
|
while True:
|
|
done, pending = await asyncio.wait(
|
|
[asyncio.create_task(queue.get()), asyncio.create_task(websocket.wait_closed())],
|
|
return_when=asyncio.FIRST_COMPLETED
|
|
)
|
|
for task in pending:
|
|
task.cancel()
|
|
# If there is a new item in client's queue, await_result will contain an item, if the connection is
|
|
# closed, it will be None.
|
|
await_result = done.pop().result()
|
|
if await_result is not None:
|
|
await self.send_reply(websocket, client_id, await_result)
|
|
else:
|
|
break
|
|
finally:
|
|
await self.unregister(websocket, client_id, queue, is_state_queue)
|
|
|
|
async def register(self, websocket, client_id: str, queue: asyncio.Queue, is_state_queue=True):
|
|
"""
|
|
Register Clients
|
|
:param websocket: The client details
|
|
:param queue: The Command Queue
|
|
:return:
|
|
"""
|
|
log.debug(f'(client_id={client_id}) WebSocket handler register')
|
|
USERS.add(websocket)
|
|
if is_state_queue:
|
|
self.state_queues.add(queue)
|
|
else:
|
|
self.message_queues.add(queue)
|
|
log.debug('WebSocket clients count: {client_count}'.format(client_count=len(USERS)))
|
|
|
|
async def unregister(self, websocket, client_id, queue, is_state_queue=True):
|
|
"""
|
|
Unregister Clients
|
|
:param websocket: The client details
|
|
:return:
|
|
"""
|
|
USERS.remove(websocket)
|
|
if is_state_queue:
|
|
self.state_queues.remove(queue)
|
|
else:
|
|
self.message_queues.remove(queue)
|
|
log.debug(f'(client_id={client_id}) WebSocket handler unregister')
|
|
log.debug('WebSocket clients count: {client_count}'.format(client_count=len(USERS)))
|
|
|
|
async def send_reply(self, websocket, client_id, reply):
|
|
json_reply = json.dumps(reply).encode()
|
|
await websocket.send(json_reply)
|
|
log.debug(f'(client_id={client_id}) WebSocket send reply: {json_reply}')
|
|
|
|
def add_state_to_queues(self, state):
|
|
"""
|
|
Inserts the state in each connection message queue
|
|
:param state: OpenLP State
|
|
"""
|
|
if not self.event_loop.is_running():
|
|
# Sometimes the event loop doesn't run when we call this method -- probably because it is shutting down
|
|
# See https://gitlab.com/openlp/openlp/-/issues/1618
|
|
return
|
|
for queue in self.state_queues.copy():
|
|
self.event_loop.call_soon_threadsafe(queue.put_nowait, state)
|
|
|
|
def add_message_to_queues(self, message: WebSocketMessage):
|
|
"""
|
|
Inserts the message in each connection message queue
|
|
:param state: OpenLP State
|
|
"""
|
|
if not self.event_loop.is_running():
|
|
# Sometimes the event loop doesn't run when we call this method -- probably because it is shutting down
|
|
# See https://gitlab.com/openlp/openlp/-/issues/1618
|
|
return
|
|
for queue in self.message_queues.copy():
|
|
self.event_loop.call_soon_threadsafe(queue.put_nowait, asdict(message))
|
|
|
|
|
|
class WebSocketServer(RegistryBase, RegistryProperties, QtCore.QObject, LogMixin):
|
|
"""
|
|
Wrapper round a server instance
|
|
"""
|
|
_send_message_signal = QtCore.pyqtSignal(WebSocketMessage)
|
|
|
|
def __init__(self):
|
|
"""
|
|
Initialise the WebSockets server
|
|
"""
|
|
super(WebSocketServer, self).__init__()
|
|
self.worker = None
|
|
self._send_message_signal.connect(self.__handle_message_signal)
|
|
|
|
def bootstrap_post_set_up(self):
|
|
self.start()
|
|
|
|
def start(self):
|
|
"""
|
|
Starts the WebSockets server
|
|
"""
|
|
if self.worker is None and not Registry().get_flag('no_web_server'):
|
|
self.worker = WebSocketWorker()
|
|
run_thread(self.worker, 'websocket_server')
|
|
poller.poller_changed.connect(self.handle_poller_signal)
|
|
# Only hooking poller signals after all UI is available
|
|
Registry().register_function('bootstrap_completion', self.try_poller_hook_signals)
|
|
|
|
@QtCore.pyqtSlot()
|
|
def handle_poller_signal(self):
|
|
if self.worker is not None:
|
|
self.worker.add_state_to_queues(poller.get_state())
|
|
|
|
def send_message(self, message: WebSocketMessage):
|
|
# Using a signal to run emission on this thread
|
|
self._send_message_signal.emit(message)
|
|
|
|
def __handle_message_signal(self, message: WebSocketMessage):
|
|
if self.worker is not None:
|
|
self.worker.add_message_to_queues(message)
|
|
|
|
def try_poller_hook_signals(self):
|
|
try:
|
|
poller.hook_signals()
|
|
except Exception:
|
|
log.error('Failed to hook poller signals!')
|
|
|
|
def close(self):
|
|
"""
|
|
Closes the WebSocket server and detach associated signals
|
|
"""
|
|
if Registry().get_flag('no_web_server'):
|
|
return
|
|
try:
|
|
poller.poller_changed.disconnect(self.handle_poller_signal)
|
|
poller.unhook_signals()
|
|
self.worker.stop()
|
|
finally:
|
|
self.worker = None
|
|
|
|
|
|
def websocket_send_message(message: WebSocketMessage):
|
|
"""
|
|
Sends a message over websocket to all connected clients.
|
|
"""
|
|
try:
|
|
if ws := Registry().get("web_socket_server"):
|
|
ws.send_message(message)
|
|
return True
|
|
except KeyError:
|
|
pass
|
|
return False
|