Added a Zeroconf service to aid devices detecting OpenLP on the network

This commit is contained in:
Raoul Snyman 2019-07-01 15:36:14 -07:00
parent 28da80ecf9
commit 0618f63ede
6 changed files with 221 additions and 14 deletions

101
openlp/core/api/zeroconf.py Normal file
View File

@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2019 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:`~openlp.core.api.zeroconf` module runs a Zerconf server so that OpenLP can advertise the
RESTful API for devices on the network to discover.
"""
import socket
from time import sleep
from zeroconf import ServiceInfo, Zeroconf
from openlp.core.common import get_local_ip4
from openlp.core.common.registry import Registry
from openlp.core.common.settings import Settings
from openlp.core.threading import ThreadWorker, run_thread
class ZeroconfWorker(ThreadWorker):
"""
This thread worker runs a Zeroconf service
"""
address = None
http_port = 4316
ws_port = 4317
_can_run = False
def __init__(self, ip_address, http_port=4316, ws_port=4317):
"""
Create the worker for the Zeroconf service
"""
super().__init__()
self.address = socket.inet_aton(ip_address)
self.http_port = http_port
self.ws_port = ws_port
def can_run(self):
"""
Check if the worker can continue to run. This is mostly so that we can override this method
and test the class.
"""
return self._can_run
def start(self):
"""
Start the service
"""
http_info = ServiceInfo('_http._tcp.local.', 'OpenLP._http._tcp.local.',
address=self.address, port=self.http_port, properties={})
ws_info = ServiceInfo('_ws._tcp.local.', 'OpenLP._ws._tcp.local.',
address=self.address, port=self.ws_port, properties={})
zc = Zeroconf()
zc.register_service(http_info)
zc.register_service(ws_info)
self._can_run = True
while self.can_run():
sleep(0.1)
zc.unregister_service(http_info)
zc.unregister_service(ws_info)
zc.close()
def stop(self):
"""
Stop the service
"""
self._can_run = False
def start_zeroconf():
"""
Start the Zeroconf service
"""
# When we're running tests, just skip this set up if this flag is set
if Registry().get_flag('no_web_server'):
return
ifaces = get_local_ip4()
for key in iter(ifaces):
address = ifaces.get(key)['ip']
break
http_port = Settings().value('api/port')
ws_port = Settings().value('api/websocket port')
worker = ZeroconfWorker(address, http_port, ws_port)
run_thread(worker, 'api_zeroconf')

View File

@ -33,8 +33,9 @@ from tempfile import gettempdir
from PyQt5 import QtCore, QtGui, QtWidgets
from openlp.core.state import State
from openlp.core.api import websockets
from openlp.core.api.http import server
from openlp.core.api.websockets import WebSocketServer
from openlp.core.api.http.server import HttpServer
from openlp.core.api.zeroconf import start_zeroconf
from openlp.core.common import add_actions, is_macosx, is_win
from openlp.core.common.actions import ActionList, CategoryOrder
from openlp.core.common.applocation import AppLocation
@ -495,8 +496,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow, LogMixin, RegistryPropert
self.copy_data = False
Settings().set_up_default_values()
self.about_form = AboutForm(self)
self.ws_server = websockets.WebSocketServer()
self.http_server = server.HttpServer(self)
self.ws_server = WebSocketServer()
self.http_server = HttpServer(self)
start_zeroconf()
SettingsForm(self)
self.formatting_tag_form = FormattingTagForm(self)
self.shortcut_form = ShortcutListForm(self)

View File

@ -31,7 +31,7 @@ from unittest.mock import patch
from openlp.core.common.json import JSONMixin, OpenLPJSONDecoder, OpenLPJSONEncoder, PathSerializer, _registered_classes
class TestClassBase(object):
class BaseTestClass(object):
"""
Simple class to avoid repetition
"""
@ -81,7 +81,7 @@ class TestJSONMixin(TestCase):
Test that an instance of a JSONMixin subclass is properly serialized to a JSON string
"""
# GIVEN: A instance of a subclass of the JSONMixin class
class TestClass(TestClassBase, JSONMixin):
class TestClass(BaseTestClass, JSONMixin):
_json_keys = ['a', 'b']
instance = TestClass(a=1, c=2)
@ -97,7 +97,7 @@ class TestJSONMixin(TestCase):
Test that an instance of a JSONMixin subclass is properly deserialized from a JSON string
"""
# GIVEN: A subclass of the JSONMixin class
class TestClass(TestClassBase, JSONMixin):
class TestClass(BaseTestClass, JSONMixin):
_json_keys = ['a', 'b']
# WHEN: Deserializing a JSON representation of the TestClass
@ -115,7 +115,7 @@ class TestJSONMixin(TestCase):
Test that an instance of a JSONMixin subclass is properly serialized to a JSON string when using a custom name
"""
# GIVEN: A instance of a subclass of the JSONMixin class with a custom name
class TestClass(TestClassBase, JSONMixin, register_names=('AltName', )):
class TestClass(BaseTestClass, JSONMixin, register_names=('AltName', )):
_json_keys = ['a', 'b']
_name = 'AltName'
_version = 2
@ -134,7 +134,7 @@ class TestJSONMixin(TestCase):
name
"""
# GIVEN: A instance of a subclass of the JSONMixin class with a custom name
class TestClass(TestClassBase, JSONMixin, register_names=('AltName', )):
class TestClass(BaseTestClass, JSONMixin, register_names=('AltName', )):
_json_keys = ['a', 'b']
_name = 'AltName'
_version = 2

View File

@ -62,9 +62,10 @@ class TestMainWindow(TestCase, TestMixin):
patch('openlp.core.ui.mainwindow.ServiceManager'), \
patch('openlp.core.ui.mainwindow.ThemeManager'), \
patch('openlp.core.ui.mainwindow.ProjectorManager'), \
patch('openlp.core.ui.mainwindow.websockets.WebSocketServer'), \
patch('openlp.core.ui.mainwindow.PluginForm'), \
patch('openlp.core.ui.mainwindow.server.HttpServer'):
patch('openlp.core.ui.mainwindow.HttpServer'), \
patch('openlp.core.ui.mainwindow.WebSocketServer'), \
patch('openlp.core.ui.mainwindow.start_zeroconf'), \
patch('openlp.core.ui.mainwindow.PluginForm'):
self.main_window = MainWindow()
def tearDown(self):

View File

@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
# vim: autoindent shiftwidth=4 expandtab textwidth=120 tabstop=4 softtabstop=4
##########################################################################
# OpenLP - Open Source Lyrics Projection #
# ---------------------------------------------------------------------- #
# Copyright (c) 2008-2019 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/>. #
##########################################################################
from unittest.mock import MagicMock, call, patch
from openlp.core.api.zeroconf import ZeroconfWorker, start_zeroconf
@patch('openlp.core.api.zeroconf.socket.inet_aton')
def test_zeroconf_worker_constructor(mocked_inet_aton):
"""Test creating the Zeroconf worker object"""
# GIVEN: A ZeroconfWorker class and a mocked inet_aton
mocked_inet_aton.return_value = 'processed_ip'
# WHEN: An instance of the ZeroconfWorker is created
worker = ZeroconfWorker('127.0.0.1', 8000, 8001)
# THEN: The inet_aton function should have been called and the attrs should be set
mocked_inet_aton.assert_called_once_with('127.0.0.1')
assert worker.address == 'processed_ip'
assert worker.http_port == 8000
assert worker.ws_port == 8001
@patch('openlp.core.api.zeroconf.ServiceInfo')
@patch('openlp.core.api.zeroconf.Zeroconf')
def test_zeroconf_worker_start(MockedZeroconf, MockedServiceInfo):
"""Test the start() method of ZeroconfWorker"""
# GIVEN: A few mocks and a ZeroconfWorker instance with a mocked can_run method
mocked_http_info = MagicMock()
mocked_ws_info = MagicMock()
mocked_zc = MagicMock()
MockedServiceInfo.side_effect = [mocked_http_info, mocked_ws_info]
MockedZeroconf.return_value = mocked_zc
worker = ZeroconfWorker('127.0.0.1', 8000, 8001)
# WHEN: The start() method is called
with patch.object(worker, 'can_run') as mocked_can_run:
mocked_can_run.side_effect = [True, False]
worker.start()
# THEN: The correct calls are made
assert MockedServiceInfo.call_args_list == [
call('_http._tcp.local.', 'OpenLP._http._tcp.local.', address=b'\x7f\x00\x00\x01', port=8000, properties={}),
call('_ws._tcp.local.', 'OpenLP._ws._tcp.local.', address=b'\x7f\x00\x00\x01', port=8001, properties={})
]
assert MockedZeroconf.call_count == 1
assert mocked_zc.register_service.call_args_list == [call(mocked_http_info), call(mocked_ws_info)]
assert mocked_can_run.call_count == 2
assert mocked_zc.unregister_service.call_args_list == [call(mocked_http_info), call(mocked_ws_info)]
assert mocked_zc.close.call_count == 1
def test_zeroconf_worker_stop():
"""Test that the ZeroconfWorker.stop() method correctly stops the service"""
# GIVEN: A worker object with _can_run set to True
worker = ZeroconfWorker('127.0.0.1', 8000, 8001)
worker._can_run = True
# WHEN: stop() is called
worker.stop()
# THEN: _can_run should be False
assert worker._can_run is False
# @patch('openlp.core.api.zeroconf.get_local_ip4')
@patch('openlp.core.api.zeroconf.Registry')
@patch('openlp.core.api.zeroconf.Settings')
@patch('openlp.core.api.zeroconf.ZeroconfWorker')
@patch('openlp.core.api.zeroconf.run_thread')
def test_start_zeroconf(mocked_run_thread, MockedZeroconfWorker, MockedSettings, MockedRegistry):
"""Test the start_zeroconf() function"""
# GIVEN: A whole bunch of stuff that's mocked out
MockedRegistry.return_value.get_flag.return_value = False
MockedSettings.return_value.value.side_effect = [8000, 8001]
mocked_worker = MagicMock()
MockedZeroconfWorker.return_value = mocked_worker
# WHEN: start_zeroconf() is called
start_zeroconf()
# THEN: A worker is added to the list of threads
mocked_run_thread.assert_called_once_with(mocked_worker, 'api_zeroconf')

View File

@ -153,8 +153,9 @@ class TestProjectorDB(TestCase, TestMixin):
patch('openlp.core.ui.mainwindow.ServiceManager'), \
patch('openlp.core.ui.mainwindow.ThemeManager'), \
patch('openlp.core.ui.mainwindow.ProjectorManager'), \
patch('openlp.core.ui.mainwindow.websockets.WebSocketServer'), \
patch('openlp.core.ui.mainwindow.server.HttpServer'), \
patch('openlp.core.ui.mainwindow.WebSocketServer'), \
patch('openlp.core.ui.mainwindow.HttpServer'), \
patch('openlp.core.ui.mainwindow.start_zeroconf'), \
patch('openlp.core.state.State.list_plugins') as mock_plugins:
mock_plugins.return_value = []
self.main_window = MainWindow()