├── mpfmonitor ├── __init__.py ├── _version.py ├── tests │ ├── test_mpfmon.py │ ├── test_devices.py │ ├── test_modes.py │ ├── test_events.py │ ├── test_inspector.py │ └── test_playfield.py ├── core │ ├── modes.py │ ├── events.py │ ├── variables.py │ ├── ui │ │ ├── searchable_tree.ui │ │ ├── searchable_table.ui │ │ └── inspector.ui │ ├── inspector.py │ ├── bcp_client.py │ ├── mpfmon.py │ ├── playfield.py │ └── devices.py └── commands │ └── monitor.py ├── setup.py ├── mpf-monitor-logo.png ├── mpf-monitor-screenshot.jpg ├── get_version.py ├── LICENSE ├── .gitignore ├── .travis.yml ├── pyproject.toml ├── README.md └── .github └── workflows └── test_build_deploy.yml /mpfmonitor/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | setup() -------------------------------------------------------------------------------- /mpf-monitor-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/missionpinball/mpf-monitor/HEAD/mpf-monitor-logo.png -------------------------------------------------------------------------------- /mpf-monitor-screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/missionpinball/mpf-monitor/HEAD/mpf-monitor-screenshot.jpg -------------------------------------------------------------------------------- /get_version.py: -------------------------------------------------------------------------------- 1 | """Return the short version string.""" 2 | from mpfmonitor._version import __short_version__ 3 | print("{}.x".format(__short_version__)) 4 | -------------------------------------------------------------------------------- /mpfmonitor/_version.py: -------------------------------------------------------------------------------- 1 | # mpf-monitor 2 | __version__ = '0.57.2.dev2' 3 | __short_version__ = '0.57' 4 | __bcp_version__ = '1.1' 5 | __config_version__ = '6' 6 | 7 | version = f"MPF Monitor v{__version__} (config_version={__config_version__}, BCP v{__bcp_version__})" 8 | -------------------------------------------------------------------------------- /mpfmonitor/tests/test_mpfmon.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import threading 3 | import os 4 | from PyQt6.QtCore import Qt 5 | from PyQt6.QtTest import QTest 6 | from mpfmonitor.core.mpfmon import * 7 | 8 | 9 | """class InitMPFMon(unittest.TestCase): 10 | @classmethod 11 | def setUpClass(self): 12 | app = QApplication(sys.argv) 13 | machine_path = os.path.join(os.getcwd(), "machine_files") 14 | self.mpfmon_sut = MainWindow(app, machine_path, None, testing=True) 15 | QTest.qWait(5000) 16 | 17 | def test_case(self): 18 | self.assertEqual(True, True) 19 | 20 | """ 21 | 22 | if __name__ == '__main__': 23 | unittest.main() 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Mission Pinball 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *.so 5 | 6 | # Distribution / packaging 7 | .Python 8 | env/ 9 | build/ 10 | develop-eggs/ 11 | dist/ 12 | downloads/ 13 | eggs/ 14 | .eggs/ 15 | lib/ 16 | lib64/ 17 | parts/ 18 | sdist/ 19 | var/ 20 | *.egg-info/ 21 | .installed.cfg 22 | *.egg 23 | 24 | # PyInstaller 25 | # Usually these files are written by a python script from a template 26 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 27 | *.manifest 28 | *.spec 29 | 30 | # Installer logs 31 | pip-log.txt 32 | pip-delete-this-directory.txt 33 | 34 | # Unit test / coverage reports 35 | htmlcov/ 36 | .tox/ 37 | .coverage 38 | .coverage.* 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | *,cover 43 | 44 | # Translations 45 | *.mo 46 | *.pot 47 | 48 | # Django stuff: 49 | *.log 50 | 51 | # Sphinx documentation 52 | docs/_build/ 53 | 54 | # PyBuilder 55 | target/ 56 | 57 | # PyCharm project files 58 | .idea/ 59 | 60 | # MPF 61 | _cache 62 | *.p 63 | 64 | # MPF build files 65 | build_scripts/wheels 66 | 67 | # Mac OS files 68 | .DS_Store 69 | 70 | # VS Code environmnet files 71 | .vscode 72 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # mpf-monitor 2 | 3 | # todo 4 | # tests 5 | # test against the multiple versions of MPF that are supported 6 | # load the graphics environment to make sure everything works? 7 | 8 | language: python 9 | 10 | python: 11 | - "3.6" 12 | 13 | before_install: 14 | - git clone --recursive --branch ${TRAVIS_BRANCH} https://github.com/missionpinball/mpf.git _mpf || git clone --recursive --branch `python3 get_version.py` https://github.com/missionpinball/mpf.git _mpf || git clone --recursive --branch dev https://github.com/missionpinball/mpf.git _mpf; 15 | 16 | install: 17 | - pip3 install --upgrade setuptools pip twine; 18 | - pip3 install -e _mpf/; 19 | 20 | script: 21 | - python3 setup.py install; 22 | 23 | deploy: 24 | skip_existing: true 25 | provider: pypi 26 | user: __token__ 27 | password: 28 | secure: "SufqFNNOHGsrLIicBK5FXiDH+DNR2SeWL/QCVk6Sd1QccZMsINvOBTeb1eZJ2jcp9pyIpIfG8BwfaFc+RiFKHq56RacjKqNUqB5UQeH2s387svMPI1nEbCvFJVp4tDKSnc9Ct/WozwH1r9jub0hI1S87V1JDxBsO68vJGS90g4111noLoEuOwYCZXZIas97bSyAXLMY1tDNFcDp7M9aKqPzE481ZOapTh2OCHqyL1cX933rmx8nEf37GKZnfjLc4TmwinmbeI597dSdztLmY6ZIQAuItuXuHdZ2aBip1rsWvtjERTLT6ZelMga9TeRxHTCubvXGF9CxM66L7bHyXfowaDwTvrxyl246Ln3LcxRwBhOQh0ekv6IXRpWF84zjrD4RXzoxVnP58hyJpZpqcXGDaZkRnU9tzTx+rCTaO2cFyVpeDm99+8UDao3QU3+nXLBnWIWxT996viQbJhzPabWxz9LsKBL69fH2BzUHKIe5S93+0rwOXS8DJluKGUKMC7BNKOylJWXkqB0qPWcMUISWxNG9xg44Jd9GG6L8IKFjDrgbcUKl40zKyA14lSYa8Jb34ZGy8hCgJqXk/aYNJEjlLK92ZzDNTsjLJkl0wsnTSRe0lnusUfm2JsDPftIA49m7fCHH/3BW6fElENYMqrbPV+E7sjJM+1VY9zGZXEHE=" 29 | on: 30 | repo: missionpinball/mpf-monitor 31 | all_branches: true 32 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "mpf-monitor" 3 | description = "MPF Monitor" 4 | readme = "README.md" 5 | requires-python = ">=3.8" 6 | license = {text = "MIT"} 7 | authors = [{ name = "The Mission Pinball Framework Team", email = "brian@fastpinball.com"}] 8 | keywords = ["pinball", "mpf"] 9 | classifiers=[ 10 | "Development Status :: 4 - Beta", 11 | "Intended Audience :: Developers", 12 | "License :: OSI Approved :: MIT License", 13 | "Programming Language :: Python :: 3.8", 14 | "Programming Language :: Python :: 3.9", 15 | "Programming Language :: Python :: 3.10", 16 | "Programming Language :: Python :: 3.11", 17 | "Programming Language :: Python :: 3.12", 18 | "Programming Language :: Python :: 3.13", 19 | "Natural Language :: English", 20 | "Operating System :: MacOS :: MacOS X", 21 | "Operating System :: Microsoft :: Windows", 22 | "Operating System :: POSIX :: Linux", 23 | "Topic :: Artistic Software", 24 | "Topic :: Games/Entertainment :: Arcade" 25 | ] 26 | dependencies = [ 27 | # Deps that MPF Monitor needs that MPF also needs are not included here 28 | "PyQt6 >= 6.4.2", # Sept 19, 2023 29 | "Pillow >= 10.4.0", # Dec 7, 2025 30 | ] 31 | dynamic = ["version"] 32 | 33 | [project.urls] 34 | homepage = "https://missionpinball.org" 35 | 36 | [project.entry-points."mpf.command"] 37 | monitor = "mpfmonitor.commands.monitor:get_command" 38 | 39 | [tool.setuptools] 40 | include-package-data = true 41 | 42 | [tool.setuptools.dynamic] 43 | version = {attr = "mpfmonitor._version.__version__"} 44 | 45 | [tool.setuptools.packages.find] 46 | include = ["mpfmonitor*"] 47 | 48 | [build-system] 49 | requires = [ 50 | "setuptools >= 63", 51 | "setuptools_scm[toml] >= 6.2", 52 | ] 53 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | MPF Monitor (mpf-monitor) 2 | ========================= 3 | 4 | 5 | 6 | This package is for the Mission Pinball Framework (MPF) Monitor 7 | (mpf-monitor). 8 | 9 | The MPF monitor is a graphical app that connects to a live running 10 | instance of MPF and shows the status of various devices. (LEDs, 11 | switches, ball locks, etc.). You can add a picture of your playfield and 12 | drag-and-drop devices to their proper locations so you can interact with 13 | your machine when you're not near your physical machine. 14 | 15 | The MPF Monitor can run on Windows, Mac, and Linux. It uses PyQt6 16 | (Python bindings for Qt6) for its visual framework. 17 | 18 | Installation & Instructions 19 | --------------------------- 20 | 21 | Full instructions for installing and using the MPF monitor are included 22 | in the MPF documentation here: https://missionpinball.org/ 23 | 24 | 25 | 26 | Support 27 | ------- 28 | 29 | MPF is open source and has no official support. Some MPF users follow the MPF-users Google group: https://groups.google.com/forum/#!forum/mpf-users. Individual hardware providers may provide additional support for users of their hardware. 30 | 31 | Contributing 32 | ------------ 33 | 34 | MPF is a passion project created and maintained by volunteers. If you're a Python coder, documentation writer, or pinball maker, feel free to make a change and submit a pull request. For more information about contributing see the [Contributing Code](https://missionpinball.org/latest/about/contributing_to_mpf/) 35 | and [Contributing Documentation](https://missionpinball.org/latest/about/help_docs/) pages. 36 | 37 | License 38 | ------- 39 | 40 | MPF and related projects are released under the MIT License. Refer to the LICENSE file for details. Docs are released under Creative Commons CC BY 4.0. 41 | -------------------------------------------------------------------------------- /mpfmonitor/tests/test_devices.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import threading 3 | import sys 4 | from PyQt6.QtTest import QTest 5 | from PyQt6 import QtCore, QtGui, QtWidgets 6 | from unittest.mock import MagicMock, patch, NonCallableMock 7 | from mpfmonitor.core.devices import * 8 | 9 | 10 | class TestableDeviceWindowNoGUI(DeviceWindow): 11 | def __init__(self, mpfmon_mock=None, logger=False): 12 | if mpfmon_mock is not None: 13 | self.mpfmon = mpfmon_mock 14 | 15 | if logger: 16 | self.log = logging.getLogger('Core') 17 | 18 | self.ui = None 19 | self.model = None 20 | 21 | self.device_states = dict() 22 | self.device_type_widgets = dict() 23 | self._debug_enabled = False 24 | 25 | class TestDeviceWindowFunctions(unittest.TestCase): 26 | def setUp(self): 27 | self.device_window = TestableDeviceWindowNoGUI() 28 | self.device_window.ui = MagicMock() 29 | self.device_window.model = MagicMock() 30 | self.device_window.filtered_model = MagicMock() 31 | 32 | @patch('mpfmonitor.core.devices.QStandardItemModel', autospec=True) 33 | @patch('mpfmonitor.core.devices.QSortFilterProxyModel', autospec=True) 34 | def test_attach_model(self, mock_standard_item, mock_proxy_item): 35 | self.device_window.attach_model() 36 | 37 | self.device_window.model.setHorizontalHeaderLabels.assert_called_once() 38 | self.device_window.filtered_model.setSourceModel.assert_called_once() 39 | self.device_window.ui.treeView.setModel.assert_called_once() 40 | 41 | @patch('mpfmonitor.core.devices.QStandardItemModel', autospec=True) 42 | @patch('mpfmonitor.core.devices.DeviceNode', autospec=True) 43 | def test_process_device_update(self, node, q_item): 44 | self.device_window.log = MagicMock() 45 | self.device_window.mpfmon = MagicMock() 46 | 47 | name = "switch1" 48 | state = {'state': 0, 'recycle_jitter_count': 0} 49 | changes = False 50 | type = "switch" 51 | 52 | self.device_window.process_device_update(name, state, changes, type) 53 | 54 | self.assertTrue(isinstance(self.device_window.device_states[type], dict)) 55 | 56 | self.device_window.model.appendRow.assert_called_once() 57 | 58 | node().setName.assert_called_once_with(name) 59 | node().setData.assert_called_with(state) 60 | node().setType.assert_called_once_with(type) 61 | 62 | # self.device_window.device_type_widgets[type].appendRow.assert_called_once_with(node.get_row()) 63 | 64 | self.device_window.mpfmon.pf.create_widget_from_config.assert_called_once_with(node(), type, name) 65 | 66 | self.device_window.device_states[type][name].setData.assert_called_with(state) 67 | 68 | 69 | def test_filter_text(self): 70 | string_in = "filter_string_test" 71 | expected_string_out = "*filter_string_test*" 72 | 73 | self.device_window.filter_text(string=string_in) 74 | 75 | self.device_window.filtered_model.setFilterWildcard.assert_called_once_with(expected_string_out) 76 | 77 | def test_change_sort_default(self): 78 | self.device_window.change_sort() 79 | self.device_window.filtered_model.sort.assert_called_once_with(2, Qt.SortOrder.AscendingOrder) 80 | 81 | def test_change_sort_time_down(self): 82 | self.device_window.change_sort(1) 83 | self.device_window.filtered_model.sort.assert_called_once_with(2, Qt.SortOrder.AscendingOrder) 84 | 85 | def test_change_sort_time_up(self): 86 | self.device_window.change_sort(2) 87 | self.device_window.filtered_model.sort.assert_called_once_with(2, Qt.SortOrder.DescendingOrder) 88 | 89 | def test_change_sort_name_up(self): 90 | self.device_window.change_sort(3) 91 | self.device_window.filtered_model.sort.assert_called_once_with(0, Qt.SortOrder.AscendingOrder) 92 | 93 | def test_change_sort_name_down(self): 94 | self.device_window.change_sort(4) 95 | self.device_window.filtered_model.sort.assert_called_once_with(0, Qt.SortOrder.DescendingOrder) 96 | 97 | 98 | 99 | if __name__ == '__main__': 100 | unittest.main() 101 | -------------------------------------------------------------------------------- /mpfmonitor/core/modes.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtCore import * 2 | from PyQt6.QtGui import * 3 | from PyQt6.QtWidgets import * 4 | from PyQt6 import uic 5 | 6 | import os 7 | import time 8 | 9 | class ModeWindow(QWidget): 10 | 11 | def __init__(self, mpfmon): 12 | self.mpfmon = mpfmon 13 | super().__init__() 14 | self.ui = None 15 | self.model = None 16 | self.draw_ui() 17 | self.attach_model() 18 | self.attach_signals() 19 | 20 | self.already_hidden = False 21 | self.added_index = 0 22 | 23 | def draw_ui(self): 24 | # Load ui file from ./ui/ 25 | ui_path = os.path.join(os.path.dirname(__file__), "ui", "searchable_table.ui") 26 | self.ui = uic.loadUi(ui_path, self) 27 | self.ui.clear_button.hide() 28 | 29 | self.ui.setWindowTitle('Running Modes') 30 | 31 | self.ui.move(self.mpfmon.local_settings.value('windows/modes/pos', 32 | QPoint(1100, 200))) 33 | self.ui.resize(self.mpfmon.local_settings.value('windows/modes/size', 34 | QSize(300, 240))) 35 | 36 | # Fix sort combobox verbiage 37 | self.ui.sortComboBox.setItemText(1, "Priority ▴") 38 | self.ui.sortComboBox.setItemText(2, "Priority ▾") 39 | 40 | 41 | # Disable option "Sort", select first item. 42 | # TODO: Store and load selected sort index to local_settings 43 | self.ui.sortComboBox.model().item(0).setEnabled(False) 44 | self.ui.sortComboBox.setCurrentIndex(1) 45 | 46 | def attach_signals(self): 47 | assert (self.ui is not None) 48 | self.ui.filterLineEdit.textChanged.connect(self.filter_text) 49 | self.ui.sortComboBox.currentIndexChanged.connect(self.change_sort) 50 | 51 | def attach_model(self): 52 | self.model = QStandardItemModel(0, 2) 53 | 54 | self.model.setHeaderData(0, Qt.Orientation.Horizontal, "Mode") 55 | self.model.setHeaderData(1, Qt.Orientation.Horizontal, "Priority") 56 | # self.model.setHeaderData(2, Qt.Orientation.Horizontal, "Time") 57 | 58 | self.filtered_model = QSortFilterProxyModel(self) 59 | self.filtered_model.setSourceModel(self.model) 60 | self.filtered_model.setFilterKeyColumn(0) 61 | self.filtered_model.setDynamicSortFilter(True) 62 | 63 | self.change_sort() # Default sort 64 | 65 | self.ui.tableView.setModel(self.filtered_model) 66 | self.ui.tableView.setColumnHidden(2, True) 67 | self.rootNode = self.model.invisibleRootItem() 68 | 69 | def process_mode_update(self, running_modes): 70 | """Update mode list.""" 71 | self.model.clear() 72 | 73 | for mode in running_modes: 74 | mode_name = QStandardItem(mode[0]) 75 | mode_priority = QStandardItem(str(mode[1])) 76 | mode_priority_padded = QStandardItem(str(mode[1]).zfill(10)) 77 | self.model.insertRow(0, [mode_name, mode_priority, mode_priority_padded]) 78 | 79 | # Reset the headers for the tree. For some reason clear() wipes these too. 80 | self.model.setHeaderData(0, Qt.Orientation.Horizontal, "Mode") 81 | self.model.setHeaderData(1, Qt.Orientation.Horizontal, "Priority") 82 | 83 | self.ui.tableView.setColumnHidden(2, True) 84 | 85 | def filter_text(self, string): 86 | wc_string = "*" + str(string) + "*" 87 | self.filtered_model.setFilterWildcard(wc_string) 88 | self.ui.tableView.resizeColumnToContents(0) 89 | self.ui.tableView.resizeColumnToContents(1) 90 | 91 | def change_sort(self, index=1): 92 | # This is a bit sloppy and probably should be reworked. 93 | if index == 1: # Received up 94 | self.filtered_model.sort(2, Qt.SortOrder.DescendingOrder) 95 | elif index == 2: # Received down 96 | self.filtered_model.sort(2, Qt.SortOrder.AscendingOrder) 97 | elif index == 3: # Name up 98 | self.filtered_model.sort(0, Qt.SortOrder.AscendingOrder) 99 | elif index == 4: # Name down 100 | self.filtered_model.sort(0, Qt.SortOrder.DescendingOrder) 101 | 102 | def closeEvent(self, event): 103 | self.mpfmon.write_local_settings() 104 | event.accept() 105 | self.mpfmon.check_if_quit() 106 | -------------------------------------------------------------------------------- /mpfmonitor/core/events.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtCore import * 2 | from PyQt6.QtGui import * 3 | from PyQt6.QtWidgets import * 4 | from PyQt6 import uic 5 | 6 | import os 7 | 8 | 9 | class EventWindow(QWidget): 10 | 11 | def __init__(self, mpfmon): 12 | self.mpfmon = mpfmon 13 | self.filtered_model = None 14 | super().__init__() 15 | self.ui = None 16 | self.model = None 17 | self.draw_ui() 18 | self.attach_model() 19 | self.attach_signals() 20 | 21 | self.already_hidden = False 22 | self.added_index = 0 23 | 24 | def draw_ui(self): 25 | # Load ui file from ./ui/ 26 | ui_path = os.path.join(os.path.dirname(__file__), "ui", "searchable_table.ui") 27 | self.ui = uic.loadUi(ui_path, self) 28 | 29 | self.ui.setWindowTitle('Events') 30 | 31 | self.ui.move(self.mpfmon.local_settings.value('windows/events/pos', 32 | QPoint(500, 200))) 33 | self.ui.resize(self.mpfmon.local_settings.value('windows/events/size', 34 | QSize(300, 600))) 35 | 36 | # Disable option "Sort", select first item. 37 | # TODO: Store and load selected sort index to local_settings 38 | self.ui.sortComboBox.model().item(0).setEnabled(False) 39 | self.ui.sortComboBox.setCurrentIndex(1) 40 | 41 | def attach_signals(self): 42 | assert (self.ui is not None) 43 | self.ui.filterLineEdit.textChanged.connect(self.filter_text) 44 | self.ui.sortComboBox.currentIndexChanged.connect(self.change_sort) 45 | self.ui.clear_button.clicked.connect(self.clear_log) 46 | 47 | def attach_model(self): 48 | self.model = QStandardItemModel(0, 2) 49 | 50 | self.model.setHeaderData(0, Qt.Orientation.Horizontal, "Event") 51 | self.model.setHeaderData(1, Qt.Orientation.Horizontal, "Data") 52 | # self.model.setHeaderData(2, Qt.Orientation.Horizontal, "Time") 53 | 54 | self.filtered_model = QSortFilterProxyModel(self) 55 | self.filtered_model.setSourceModel(self.model) 56 | self.filtered_model.setFilterKeyColumn(0) 57 | self.filtered_model.setDynamicSortFilter(True) 58 | 59 | self.change_sort() # Default sort 60 | 61 | self.ui.tableView.setModel(self.filtered_model) 62 | self.ui.tableView.setColumnHidden(2, True) 63 | self.rootNode = self.model.invisibleRootItem() 64 | 65 | def add_event_to_model(self, event_name, event_type, event_callback, 66 | event_kwargs, registered_handlers): 67 | """Add an event.""" 68 | assert(self.model is not None) 69 | # remove _from_bcp arg 70 | event_kwargs.pop('_from_bcp', False) 71 | 72 | name = QStandardItem(event_name) 73 | kwargs = QStandardItem(str(event_kwargs)) 74 | time_added = QStandardItem(str(self.added_index).zfill(10)) 75 | self.added_index += 1 76 | self.model.insertRow(0, [name, kwargs, time_added]) 77 | 78 | def update_events(self): 79 | """Update view.""" 80 | self.ui.tableView.resizeColumnToContents(0) 81 | self.ui.tableView.resizeColumnToContents(1) 82 | 83 | if not self.already_hidden: 84 | self.ui.tableView.setColumnHidden(2, True) 85 | self.already_hidden = True 86 | 87 | def filter_text(self, string): 88 | wc_string = "*" + str(string) + "*" 89 | self.filtered_model.setFilterWildcard(wc_string) 90 | self.ui.tableView.resizeColumnToContents(0) 91 | self.ui.tableView.resizeColumnToContents(1) 92 | 93 | def change_sort(self, index=1): 94 | # This is a bit sloppy and probably should be reworked. 95 | if index == 1: # Received up 96 | self.filtered_model.sort(2, Qt.SortOrder.DescendingOrder) 97 | elif index == 2: # Received down 98 | self.filtered_model.sort(2, Qt.SortOrder.AscendingOrder) 99 | elif index == 3: # Name up 100 | self.filtered_model.sort(0, Qt.SortOrder.AscendingOrder) 101 | elif index == 4: # Name down 102 | self.filtered_model.sort(0, Qt.SortOrder.DescendingOrder) 103 | 104 | def clear_log(self): 105 | #clears the log of events 106 | self.model.clear() 107 | 108 | def closeEvent(self, event): 109 | self.mpfmon.write_local_settings() 110 | event.accept() 111 | self.mpfmon.check_if_quit() 112 | -------------------------------------------------------------------------------- /mpfmonitor/core/variables.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtCore import * 2 | from PyQt6.QtGui import * 3 | from PyQt6.QtWidgets import * 4 | from PyQt6 import uic 5 | 6 | import os 7 | import time 8 | 9 | class VariableWindow(QWidget): 10 | 11 | def __init__(self, mpfmon): 12 | self.mpfmon = mpfmon 13 | super().__init__() 14 | self.ui = None 15 | self.model = None 16 | self.draw_ui() 17 | self.attach_model() 18 | self.attach_signals() 19 | 20 | self.already_hidden = False 21 | self.added_index = 0 22 | 23 | self.variables = dict() # keys are tuples of (variable, type), values are the var's value model 24 | 25 | def draw_ui(self): 26 | # Load ui file from ./ui/ 27 | ui_path = os.path.join(os.path.dirname(__file__), "ui", "searchable_table.ui") 28 | self.ui = uic.loadUi(ui_path, self) 29 | self.ui.clear_button.hide() 30 | 31 | self.ui.setWindowTitle('Player/Machine Variables') 32 | 33 | self.ui.move(self.mpfmon.local_settings.value('windows/variables/pos', 34 | QPoint(1100, 200))) 35 | self.ui.resize(self.mpfmon.local_settings.value('windows/variables/size', 36 | QSize(300, 240))) 37 | 38 | # Fix sort combobox verbiage 39 | self.ui.sortComboBox.setItemText(1, "Name ▴") 40 | self.ui.sortComboBox.setItemText(2, "Name ▾") 41 | self.ui.sortComboBox.setItemText(3, "Value ▴") 42 | self.ui.sortComboBox.setItemText(4, "Value ▾") 43 | 44 | # Disable option "Sort", select first item. 45 | # TODO: Store and load selected sort index to local_settings 46 | self.ui.sortComboBox.model().item(0).setEnabled(False) 47 | self.ui.sortComboBox.setCurrentIndex(1) 48 | 49 | 50 | def attach_signals(self): 51 | assert (self.ui is not None) 52 | self.ui.filterLineEdit.textChanged.connect(self.filter_text) 53 | self.ui.sortComboBox.currentIndexChanged.connect(self.change_sort) 54 | 55 | 56 | def attach_model(self): 57 | self.model = QStandardItemModel(0, 3) 58 | 59 | self.model.setHeaderData(0, Qt.Orientation.Horizontal, "Type") 60 | self.model.setHeaderData(1, Qt.Orientation.Horizontal, "Variable Name") 61 | self.model.setHeaderData(2, Qt.Orientation.Horizontal, "Value") 62 | 63 | self.filtered_model = QSortFilterProxyModel(self) 64 | self.filtered_model.setSourceModel(self.model) 65 | self.filtered_model.setFilterKeyColumn(1) 66 | self.filtered_model.setDynamicSortFilter(True) 67 | 68 | self.change_sort() # Default sort 69 | 70 | self.ui.tableView.setModel(self.filtered_model) 71 | # self.ui.tableView.setColumnHidden(2, True) 72 | self.rootNode = self.model.invisibleRootItem() 73 | 74 | def update_variable(self, var_type, variable, value): 75 | """Update variables. 76 | 77 | type: Str of what MPF type the variable is. 'machine', 'player', 78 | variable: name of the variable 79 | value: value of the variable 80 | 81 | """ 82 | 83 | if (variable, var_type) in self.variables: 84 | self.variables[(variable, var_type)].setData(str(value), Qt.ItemDataRole.DisplayRole) 85 | else: 86 | value_model = QStandardItem(str(value)) 87 | self.variables[(variable, var_type)] = value_model 88 | self.model.insertRow(0, [QStandardItem(var_type), QStandardItem(str(variable)), value_model]) 89 | 90 | def filter_text(self, string): 91 | wc_string = "*" + str(string) + "*" 92 | self.filtered_model.setFilterWildcard(wc_string) 93 | self.ui.tableView.resizeColumnToContents(0) 94 | self.ui.tableView.resizeColumnToContents(1) 95 | 96 | def change_sort(self, index=1): 97 | # This is a bit sloppy and probably should be reworked. 98 | if index == 1: # Name up 99 | self.filtered_model.sort(1, Qt.SortOrder.DescendingOrder) 100 | elif index == 2: # Name down 101 | self.filtered_model.sort(1, Qt.SortOrder.AscendingOrder) 102 | elif index == 3: # Value up 103 | self.filtered_model.sort(2, Qt.SortOrder.AscendingOrder) 104 | elif index == 4: # Value down 105 | self.filtered_model.sort(2, Qt.SortOrder.DescendingOrder) 106 | 107 | def closeEvent(self, event): 108 | self.mpfmon.write_local_settings() 109 | event.accept() 110 | self.mpfmon.check_if_quit() 111 | -------------------------------------------------------------------------------- /mpfmonitor/core/ui/searchable_tree.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | DevicesWindow 4 | 5 | 6 | 7 | 0 8 | 0 9 | 300 10 | 500 11 | 12 | 13 | 14 | Form 15 | 16 | 17 | 18 | 5 19 | 20 | 21 | 3 22 | 23 | 24 | 2 25 | 26 | 27 | 3 28 | 29 | 30 | 31 | 32 | 5 33 | 34 | 35 | 36 | 37 | true 38 | 39 | 40 | Search for devices 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | Filter 50 | 51 | 52 | true 53 | 54 | 55 | 56 | 57 | 58 | 59 | true 60 | 61 | 62 | 63 | 64 | 65 | 66 | Sort 67 | 68 | 69 | 70 | 71 | Recieved ▴ 72 | 73 | 74 | 75 | 76 | Recieved ▾ 77 | 78 | 79 | 80 | 81 | Name ▴ 82 | 83 | 84 | 85 | 86 | Name ▾ 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | true 95 | 96 | 97 | 98 | 0 99 | 0 100 | 101 | 102 | 103 | true 104 | 105 | 106 | Qt::ScrollBarAsNeeded 107 | 108 | 109 | Qt::ScrollBarAsNeeded 110 | 111 | 112 | QAbstractScrollArea::AdjustToContents 113 | 114 | 115 | QAbstractItemView::NoEditTriggers 116 | 117 | 118 | false 119 | 120 | 121 | false 122 | 123 | 124 | QAbstractItemView::DragOnly 125 | 126 | 127 | true 128 | 129 | 130 | QAbstractItemView::SingleSelection 131 | 132 | 133 | QAbstractItemView::SelectRows 134 | 135 | 136 | QAbstractItemView::ScrollPerItem 137 | 138 | 139 | QAbstractItemView::ScrollPerPixel 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | -------------------------------------------------------------------------------- /mpfmonitor/core/ui/searchable_table.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | DevicesWindow 4 | 5 | 6 | 7 | 0 8 | 0 9 | 367 10 | 500 11 | 12 | 13 | 14 | Form 15 | 16 | 17 | 18 | 5 19 | 20 | 21 | 3 22 | 23 | 24 | 2 25 | 26 | 27 | 3 28 | 29 | 30 | 31 | 32 | 5 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | Filter 41 | 42 | 43 | true 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | Sort 52 | 53 | 54 | 55 | 56 | Recieved ▴ 57 | 58 | 59 | 60 | 61 | Recieved ▾ 62 | 63 | 64 | 65 | 66 | Name ▴ 67 | 68 | 69 | 70 | 71 | Name ▾ 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | true 80 | 81 | 82 | 83 | 0 84 | 0 85 | 86 | 87 | 88 | true 89 | 90 | 91 | Qt::ScrollBarAsNeeded 92 | 93 | 94 | Qt::ScrollBarAsNeeded 95 | 96 | 97 | QAbstractScrollArea::AdjustToContents 98 | 99 | 100 | QAbstractItemView::NoEditTriggers 101 | 102 | 103 | false 104 | 105 | 106 | true 107 | 108 | 109 | QAbstractItemView::SingleSelection 110 | 111 | 112 | QAbstractItemView::SelectRows 113 | 114 | 115 | QAbstractItemView::ScrollPerItem 116 | 117 | 118 | QAbstractItemView::ScrollPerPixel 119 | 120 | 121 | false 122 | 123 | 124 | false 125 | 126 | 127 | 20 128 | 129 | 130 | 20 131 | 132 | 133 | 134 | 135 | 136 | 137 | Clear 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | -------------------------------------------------------------------------------- /mpfmonitor/commands/monitor.py: -------------------------------------------------------------------------------- 1 | """Starts the MPF Monitor.""" 2 | 3 | import argparse 4 | import logging 5 | import os 6 | import socket 7 | import sys 8 | import threading 9 | from datetime import datetime 10 | import time 11 | 12 | import errno 13 | from mpfmonitor._version import __version__ 14 | 15 | # import functiontrace 16 | # functiontrace.trace() 17 | 18 | class Command(object): 19 | 20 | # pylint: disable-msg=too-many-locals 21 | def __init__(self, mpf_path, machine_path, args): 22 | 23 | # Need to have these in here because we don't want them to load when 24 | # the module is loaded as an mpf.command 25 | from mpf.core.utility_functions import Util 26 | 27 | del mpf_path 28 | 29 | parser = argparse.ArgumentParser(description='Starts the MPF Monitor') 30 | 31 | parser.add_argument("-l", 32 | action="store", dest="logfile", 33 | metavar='file_name', 34 | default=os.path.join("logs", datetime.now().strftime( 35 | "%Y-%m-%d-%H-%M-%S-monitor-" + 36 | socket.gethostname() + 37 | ".log")), 38 | help="The name (and path) of the log file") 39 | 40 | parser.add_argument("-c", 41 | action="store", dest="configfile", 42 | default="monitor", metavar='config_file(s)', 43 | help="The name of a config file to load. Note " 44 | "this is a config for the monitor itself, " 45 | "not an MPF config.yaml. Default is " 46 | "monitor.") 47 | 48 | parser.add_argument("-v", 49 | action="store_const", dest="loglevel", const=logging.DEBUG, 50 | default=logging.INFO, help="Enables verbose logging to the" 51 | " log file") 52 | 53 | parser.add_argument("-V", 54 | action="store_true", dest="consoleloglevel", 55 | default=logging.INFO, 56 | help="Enables verbose logging to the console. Do NOT on " 57 | "Windows platforms") 58 | 59 | parser.add_argument("-C", 60 | action="store", dest="mpfmonconfigfile", 61 | default="mpfmonitor.yaml", 62 | metavar='config_file', 63 | help="The MPF Monitor default config file. " 64 | "Default is /mpfmonitor.yaml") 66 | 67 | parser.add_argument("-ip", 68 | action="store", dest="mpfipaddr", 69 | help="The MPF IP Address Default is localhost") 70 | 71 | parser.add_argument("-port", 72 | action="store", dest="mpfport", 73 | help="The MPF Port Default is 5051") 74 | 75 | args = parser.parse_args(args) 76 | args.configfile = "{}.yaml".format(args.configfile) 77 | 78 | # Configure logging. Creates a logfile and logs to the console. 79 | # Formatting options are documented here: 80 | # https://docs.python.org/2.7/library/logging.html#logrecord-attributes 81 | 82 | try: 83 | os.makedirs(os.path.join(machine_path, 'logs')) 84 | except OSError as exception: 85 | if exception.errno != errno.EEXIST: 86 | raise 87 | 88 | logging.basicConfig(level=args.loglevel, 89 | format='%(asctime)s : %(levelname)s : %(name)s : ' 90 | '%(message)s', 91 | filename=os.path.join(machine_path, args.logfile), 92 | filemode='w') 93 | 94 | # define a Handler which writes INFO messages or higher to the 95 | # sys.stderr 96 | console = logging.StreamHandler() 97 | console.setLevel(args.consoleloglevel) 98 | 99 | # set a format which is simpler for console use 100 | formatter = logging.Formatter('%(levelname)s : %(name)s : %(message)s') 101 | 102 | # tell the handler to use this format 103 | console.setFormatter(formatter) 104 | 105 | # add the handler to the root logger 106 | logging.getLogger('').addHandler(console) 107 | 108 | from mpfmonitor.core.mpfmon import run 109 | 110 | logging.info("Loading MPF Monitor Version {}".format(__version__)) 111 | 112 | thread_stopper = threading.Event() 113 | 114 | try: 115 | run(machine_path=machine_path, 116 | thread_stopper=thread_stopper, 117 | config_file=args.configfile, 118 | ip_addr=args.mpfipaddr, 119 | port=args.mpfport) 120 | logging.info("MPF Monitor run loop ended.") 121 | except Exception as e: 122 | logging.exception(str(e)) 123 | 124 | logging.info("Stopping child threads... (%s remaining)", 125 | len(threading.enumerate()) - 1) 126 | 127 | thread_stopper.set() 128 | 129 | while len(threading.enumerate()) > 1: 130 | time.sleep(.1) 131 | 132 | logging.info("All child threads stopped.") 133 | 134 | sys.exit() 135 | 136 | def get_command(): 137 | return 'monitor', Command 138 | -------------------------------------------------------------------------------- /.github/workflows/test_build_deploy.yml: -------------------------------------------------------------------------------- 1 | name: Test on all platforms 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tests: 7 | name: Test Python ${{ matrix.python-version }} on ${{ matrix.os }} 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | include: 13 | - os: windows-latest 14 | python-version: 3.8 15 | mpf-version: 0.57.3 16 | - os: windows-latest 17 | python-version: 3.9 18 | mpf-version: 0.57.3 19 | - os: windows-latest 20 | python-version: '3.10' 21 | mpf-version: 0.57.3 22 | - os: windows-latest 23 | python-version: '3.11' 24 | mpf-version: 0.57.3 25 | - os: windows-latest 26 | python-version: '3.12' 27 | mpf-version: 0.57.3 28 | 29 | - os: ubuntu-24.04 30 | python-version: 3.8 31 | mpf-version: 0.57.3 32 | - os: ubuntu-24.04 33 | python-version: 3.9 34 | mpf-version: 0.57.3 35 | - os: ubuntu-24.04 36 | python-version: '3.10' 37 | mpf-version: 0.57.3 38 | - os: ubuntu-24.04 39 | python-version: '3.11' 40 | mpf-version: 0.57.3 41 | - os: ubuntu-24.04 42 | python-version: '3.12' 43 | mpf-version: 0.57.3 44 | 45 | - os: macos-latest 46 | python-version: 3.8 47 | mpf-version: 0.57.3 48 | - os: macos-latest 49 | python-version: 3.9 50 | mpf-version: 0.57.3 51 | - os: macos-latest 52 | python-version: '3.10' 53 | mpf-version: 0.57.3 54 | - os: macos-latest 55 | python-version: '3.11' 56 | mpf-version: 0.57.3 57 | - os: macos-latest 58 | python-version: '3.12' 59 | mpf-version: 0.57.3 60 | 61 | - os: windows-latest 62 | python-version: '3.13' 63 | mpf-version: 0.80.0.dev11 64 | - os: ubuntu-24.04 65 | python-version: '3.13' 66 | mpf-version: 0.80.0.dev11 67 | - os: macos-latest 68 | python-version: '3.13' 69 | mpf-version: 0.80.0.dev11 70 | 71 | steps: 72 | - name: Checkout mpf-monitor 73 | uses: actions/checkout@v4 74 | 75 | - name: Setup python 76 | uses: actions/setup-python@v5 77 | with: 78 | python-version: ${{ matrix.python-version }} 79 | 80 | - name: install Ubuntu dependencies 81 | if: matrix.os == 'ubuntu-24.04' 82 | run: | 83 | sudo apt-get update 84 | sudo apt-get install -y '^libxcb.*-dev' libx11-xcb-dev libjpeg-dev 85 | sudo apt-get install -y qt6-base-dev 86 | 87 | - name: Install mpf-monitor 88 | run: | 89 | pip install --upgrade pip setuptools wheel build coveralls 90 | pip install mpf==${{ matrix.mpf-version }} 91 | pip install -e . 92 | 93 | - name: Lint with flake8 94 | run: | 95 | pip install flake8 pytest 96 | # stop the build if there are Python syntax errors or undefined names 97 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 98 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 99 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 100 | 101 | - name: Run tests 102 | run: coverage run -m unittest discover -s mpfmonitor/tests 103 | env: 104 | QT_QPA_PLATFORM: offscreen 105 | 106 | - name: Upload coverage data to coveralls.io 107 | run: coveralls --service=github 108 | env: 109 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 110 | COVERALLS_FLAG_NAME: ${{ matrix.python-version }}-${{ matrix.os }} 111 | COVERALLS_PARALLEL: true 112 | 113 | coveralls: 114 | name: Indicate completion to coveralls.io 115 | needs: tests 116 | runs-on: ubuntu-latest 117 | container: python:3-slim 118 | steps: 119 | - name: Finished 120 | run: | 121 | pip3 install --upgrade coveralls 122 | coveralls --service=github --finish 123 | env: 124 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 125 | 126 | build_wheels: 127 | name: Build wheel 128 | needs: tests 129 | runs-on: ubuntu-latest 130 | 131 | steps: 132 | - name: Checkout mpf-monitor 133 | uses: actions/checkout@v4 134 | 135 | - name: Setup python 136 | uses: actions/setup-python@v5 137 | with: 138 | python-version: 3.12 139 | 140 | - name: Install mpf-monitor 141 | run: | 142 | sudo apt-get update 143 | sudo apt-get install -y '^libxcb.*-dev' libx11-xcb-dev libjpeg-dev 144 | sudo apt-get install -y qt6-base-dev 145 | pip install --upgrade pip setuptools wheel build 146 | pip install mpf==0.57.3 147 | pip install -e . 148 | 149 | - name: Build wheel 150 | run: python -m build 151 | 152 | - uses: actions/upload-artifact@v4 153 | with: 154 | name: mpf-monitor-wheels 155 | path: ./dist/*.* 156 | 157 | publish_to_pypi: # only if this release has a tag and is a push from us (e.g. not a pull request) 158 | name: Publish to PYPI 159 | needs: build_wheels 160 | runs-on: ubuntu-latest 161 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 162 | environment: 163 | name: pypi 164 | url: https://pypi.org/p/mpf 165 | permissions: 166 | id-token: write 167 | steps: 168 | - name: Download wheels 169 | uses: actions/download-artifact@v6 170 | with: 171 | name: mpf-monitor-wheels 172 | path: dist 173 | - name: Publish to PyPI 174 | uses: pypa/gh-action-pypi-publish@release/v1 175 | -------------------------------------------------------------------------------- /mpfmonitor/tests/test_modes.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest import TestCase 3 | import sys 4 | 5 | from mpfmonitor.core.modes import * 6 | from unittest.mock import MagicMock 7 | 8 | 9 | class TestableModeNoGUI(ModeWindow): 10 | def __init__(self, mpfmon_mock=None): 11 | if mpfmon_mock is not None: 12 | self.mpfmon = mpfmon_mock 13 | 14 | self.ui = None 15 | self.model = None 16 | 17 | 18 | class TestModeWindowFunctions(unittest.TestCase): 19 | 20 | def setUp(self): 21 | self.mode_window = TestableModeNoGUI() 22 | 23 | self.mode_window.ui = MagicMock() 24 | self.mode_window.model = MagicMock() 25 | self.mode_window.filtered_model = MagicMock() 26 | 27 | def test_process_mode_update(self): 28 | 29 | modes_in = [ 30 | ["mode1", 100], 31 | ["mode2", 1000], 32 | ["mode3", 10000] 33 | ] 34 | 35 | self.mode_window.process_mode_update(running_modes=modes_in) 36 | 37 | self.mode_window.model.clear.assert_called_once() 38 | 39 | # for mode in modes_in: 40 | # mode_name = QStandardItem(mode[0]) 41 | # mode_priority = QStandardItem(str(mode[1])) 42 | # mode_priority_padded = QStandardItem(str(mode[1]).zfill(10)) 43 | # 44 | # self.mode_window.model.insertRow.assert_called_with(0, [mode_name, mode_priority, mode_priority_padded]) 45 | 46 | # For now, just test it's called as many times as there are modes. 47 | self.assertEqual(self.mode_window.model.insertRow.call_count, len(modes_in)) 48 | 49 | def test_filter_text(self): 50 | string_in = "filter_string_test" 51 | expected_string_out = "*filter_string_test*" 52 | 53 | self.mode_window.filter_text(string=string_in) 54 | 55 | self.mode_window.filtered_model.setFilterWildcard.assert_called_once_with(expected_string_out) 56 | 57 | def test_change_sort_default(self): 58 | self.mode_window.change_sort() 59 | self.mode_window.filtered_model.sort.assert_called_once_with(2, Qt.SortOrder.DescendingOrder) 60 | 61 | def test_change_sort_time_down(self): 62 | self.mode_window.change_sort(1) 63 | self.mode_window.filtered_model.sort.assert_called_once_with(2, Qt.SortOrder.DescendingOrder) 64 | 65 | def test_change_sort_time_up(self): 66 | self.mode_window.change_sort(2) 67 | self.mode_window.filtered_model.sort.assert_called_once_with(2, Qt.SortOrder.AscendingOrder) 68 | 69 | def test_change_sort_name_up(self): 70 | self.mode_window.change_sort(3) 71 | self.mode_window.filtered_model.sort.assert_called_once_with(0, Qt.SortOrder.AscendingOrder) 72 | 73 | def test_change_sort_name_down(self): 74 | self.mode_window.change_sort(4) 75 | self.mode_window.filtered_model.sort.assert_called_once_with(0, Qt.SortOrder.DescendingOrder) 76 | 77 | 78 | app = QApplication(sys.argv) 79 | 80 | 81 | class TestModeWindowGUI(unittest.TestCase): 82 | @classmethod 83 | def setUpClass(self): 84 | mock_mpfmon = MagicMock() 85 | mock_mpfmon.local_settings.value.side_effect = [QPoint(1100, 200), QSize(300, 250)] 86 | 87 | self.mode_window = ModeWindow(mock_mpfmon) 88 | 89 | self.mock_event_kwargs = MagicMock() 90 | self.mock_event_kwargs.__str__ = MagicMock(return_value='{args}') 91 | self.mock_event_kwargs.pop.return_value(False) 92 | 93 | def test_model(self): 94 | self.assertIsNotNone(self.mode_window.model) 95 | 96 | def test_empty_table(self): 97 | # Reset table model 98 | self.mode_window.attach_model() 99 | 100 | # Check it's empty 101 | self.assertEqual(self.mode_window.model.rowCount(), 0) 102 | self.assertEqual(self.mode_window.filtered_model.rowCount(), 0) 103 | 104 | def test_add_to_table(self): 105 | # Reset table model 106 | self.mode_window.attach_model() 107 | self.assertEqual(self.mode_window.filtered_model.rowCount(), 0) 108 | 109 | modes_in = [ 110 | ["mode1", 100], 111 | ["mode2", 1000], 112 | ["mode3", 10000] 113 | ] 114 | 115 | self.mode_window.process_mode_update(running_modes=modes_in) 116 | 117 | # Check table has 3 rows 118 | self.assertEqual(self.mode_window.filtered_model.rowCount(), 3) 119 | 120 | 121 | def test_sort(self): 122 | # Reset table model 123 | self.mode_window.attach_model() 124 | self.assertEqual(self.mode_window.filtered_model.rowCount(), 0) 125 | 126 | modes_in = [ 127 | ["mode1", 100], 128 | ["mode2", 1000], 129 | ["mode3", 10000] 130 | ] 131 | 132 | self.mode_window.process_mode_update(running_modes=modes_in) 133 | 134 | # Default is Received up 135 | top_row_text = self.mode_window.filtered_model.index(0, 0).data() 136 | self.assertEqual(top_row_text, modes_in[-1][0]) 137 | 138 | # Sort Received up 139 | self.mode_window.ui.sortComboBox.setCurrentIndex(1) 140 | top_row_text = self.mode_window.filtered_model.index(0, 0).data() 141 | self.assertEqual(top_row_text, modes_in[-1][0]) 142 | 143 | # Sort Received down 144 | self.mode_window.ui.sortComboBox.setCurrentIndex(2) 145 | top_row_text = self.mode_window.filtered_model.index(0, 0).data() 146 | self.assertEqual(top_row_text, modes_in[0][0]) 147 | 148 | # Sort Name up 149 | self.mode_window.ui.sortComboBox.setCurrentIndex(3) 150 | top_row_text = self.mode_window.filtered_model.index(0, 0).data() 151 | self.assertEqual(top_row_text, modes_in[0][0]) 152 | 153 | # Sort Name down 154 | self.mode_window.ui.sortComboBox.setCurrentIndex(4) 155 | top_row_text = self.mode_window.filtered_model.index(0, 0).data() 156 | self.assertEqual(top_row_text, modes_in[-1][0]) 157 | 158 | def test_filter(self): 159 | # Reset table model 160 | self.mode_window.attach_model() 161 | self.assertEqual(self.mode_window.filtered_model.rowCount(), 0) 162 | 163 | modes_in = [ 164 | ["mode1", 100], 165 | ["mode32", 1000], 166 | ["mode3", 10000] 167 | ] 168 | 169 | self.mode_window.process_mode_update(running_modes=modes_in) 170 | 171 | # Make sure filter is empty and check none are filtered 172 | self.mode_window.ui.filterLineEdit.setText("") 173 | self.assertEqual(self.mode_window.filtered_model.rowCount(), len(modes_in)) 174 | 175 | # Set the filter to a unique string and check it returns 1 match 176 | self.mode_window.ui.filterLineEdit.setText(modes_in[0][0]) 177 | self.assertEqual(self.mode_window.filtered_model.rowCount(), 1) 178 | 179 | # Set the filter to a non-unique string and check it returns 2 matches 180 | self.mode_window.ui.filterLineEdit.setText(modes_in[2][0]) 181 | self.assertEqual(self.mode_window.filtered_model.rowCount(), 2) 182 | 183 | 184 | if __name__ == '__main__': 185 | unittest.main() 186 | -------------------------------------------------------------------------------- /mpfmonitor/tests/test_events.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import threading 3 | import sys 4 | from PyQt6.QtTest import QTest 5 | from PyQt6 import QtCore, QtGui, QtWidgets 6 | from unittest.mock import MagicMock 7 | from mpfmonitor.core.events import * 8 | 9 | 10 | class TestableEventNoGUI(EventWindow): 11 | def __init__(self, mpfmon_mock=None): 12 | if mpfmon_mock is not None: 13 | self.mpfmon = mpfmon_mock 14 | 15 | self.ui = None 16 | self.model = None 17 | 18 | self.already_hidden = False 19 | self.added_index = 0 20 | 21 | 22 | class TestEventWindowFunctions(unittest.TestCase): 23 | 24 | def setUp(self): 25 | self.event_window = TestableEventNoGUI() 26 | 27 | self.event_window.ui = MagicMock() 28 | self.event_window.model = MagicMock() 29 | self.event_window.filtered_model = MagicMock() 30 | 31 | self.mock_event_kwargs = MagicMock() 32 | self.mock_event_kwargs.__str__ = MagicMock(return_value='{args}') 33 | self.mock_event_kwargs.pop.return_value(False) 34 | 35 | def test_add_event_to_model(self): 36 | self.assertEqual(self.event_window.already_hidden, False) 37 | 38 | self.event_window.add_event_to_model("event1", None, None, self.mock_event_kwargs, None) 39 | 40 | self.event_window.model.insertRow.assert_called_once() 41 | # Disabled by Brian because this assert fails, but I don't know what it's actually testing, feel free to fix & re-enable :) 42 | # self.assertEqual(self.event_window.already_hidden, True) 43 | 44 | def test_filter_text(self): 45 | string_in = "filter_string_test" 46 | expected_string_out = "*filter_string_test*" 47 | 48 | self.event_window.filter_text(string=string_in) 49 | 50 | self.event_window.filtered_model.setFilterWildcard.assert_called_once_with(expected_string_out) 51 | 52 | def test_change_sort_default(self): 53 | self.event_window.change_sort() 54 | self.event_window.filtered_model.sort.assert_called_once_with(2, Qt.SortOrder.DescendingOrder) 55 | 56 | def test_change_sort_time_down(self): 57 | self.event_window.change_sort(1) 58 | self.event_window.filtered_model.sort.assert_called_once_with(2, Qt.SortOrder.DescendingOrder) 59 | 60 | def test_change_sort_time_up(self): 61 | self.event_window.change_sort(2) 62 | self.event_window.filtered_model.sort.assert_called_once_with(2, Qt.SortOrder.AscendingOrder) 63 | 64 | def test_change_sort_name_up(self): 65 | self.event_window.change_sort(3) 66 | self.event_window.filtered_model.sort.assert_called_once_with(0, Qt.SortOrder.AscendingOrder) 67 | 68 | def test_change_sort_name_down(self): 69 | self.event_window.change_sort(4) 70 | self.event_window.filtered_model.sort.assert_called_once_with(0, Qt.SortOrder.DescendingOrder) 71 | 72 | 73 | app = QApplication(sys.argv) 74 | 75 | 76 | class TestEvents(unittest.TestCase): 77 | @classmethod 78 | def setUpClass(self): 79 | mock_mpfmon = MagicMock() 80 | mock_mpfmon.local_settings.value.side_effect = [QPoint(500, 200), QSize(300, 600)] 81 | 82 | self.eventWindow = EventWindow(mock_mpfmon) 83 | 84 | self.mock_event_kwargs = MagicMock() 85 | self.mock_event_kwargs.__str__ = MagicMock(return_value='{args}') 86 | self.mock_event_kwargs.pop.return_value(False) 87 | 88 | def test_model(self): 89 | self.assertIsNotNone(self.eventWindow.model) 90 | 91 | def test_empty_table(self): 92 | # Reset table model 93 | self.eventWindow.attach_model() 94 | 95 | # Check it's empty 96 | self.assertEqual(self.eventWindow.model.rowCount(), 0) 97 | self.assertEqual(self.eventWindow.filtered_model.rowCount(), 0) 98 | 99 | def test_add_to_table(self): 100 | # Reset table model 101 | self.eventWindow.attach_model() 102 | self.assertEqual(self.eventWindow.filtered_model.rowCount(), 0) 103 | 104 | self.eventWindow.add_event_to_model("event1", None, None, self.mock_event_kwargs, None) 105 | 106 | # Check table has 1 row 107 | self.assertEqual(self.eventWindow.filtered_model.rowCount(), 1) 108 | 109 | self.eventWindow.add_event_to_model("event2", None, None, self.mock_event_kwargs, None) 110 | self.eventWindow.add_event_to_model("event3", None, None, self.mock_event_kwargs, None) 111 | 112 | # Check table has 3 rows 113 | self.assertEqual(self.eventWindow.filtered_model.rowCount(), 3) 114 | 115 | def test_sort(self): 116 | # Reset table model 117 | self.eventWindow.attach_model() 118 | self.assertEqual(self.eventWindow.filtered_model.rowCount(), 0) 119 | 120 | event_list = ["event_a", "event_b", "event_c"] 121 | 122 | for e in event_list: 123 | self.eventWindow.add_event_to_model(e, None, None, self.mock_event_kwargs, None) 124 | 125 | # Default is Received up 126 | top_row_text = self.eventWindow.filtered_model.index(0, 0).data() 127 | self.assertEqual(top_row_text, event_list[-1]) 128 | 129 | # Sort Received up 130 | self.eventWindow.ui.sortComboBox.setCurrentIndex(1) 131 | top_row_text = self.eventWindow.filtered_model.index(0, 0).data() 132 | self.assertEqual(top_row_text, event_list[-1]) 133 | 134 | # Sort Received down 135 | self.eventWindow.ui.sortComboBox.setCurrentIndex(2) 136 | top_row_text = self.eventWindow.filtered_model.index(0, 0).data() 137 | self.assertEqual(top_row_text, event_list[0]) 138 | 139 | # Sort Name up 140 | self.eventWindow.ui.sortComboBox.setCurrentIndex(3) 141 | top_row_text = self.eventWindow.filtered_model.index(0, 0).data() 142 | self.assertEqual(top_row_text, event_list[0]) 143 | 144 | # Sort Name down 145 | self.eventWindow.ui.sortComboBox.setCurrentIndex(4) 146 | top_row_text = self.eventWindow.filtered_model.index(0, 0).data() 147 | self.assertEqual(top_row_text, event_list[-1]) 148 | 149 | def test_filter(self): 150 | # Reset table model 151 | self.eventWindow.attach_model() 152 | self.assertEqual(self.eventWindow.filtered_model.rowCount(), 0) 153 | 154 | event_list = ["abc", "def", "ghi", "ghijkl"] 155 | 156 | for e in event_list: 157 | self.eventWindow.add_event_to_model(e, None, None, self.mock_event_kwargs, None) 158 | 159 | # Make sure filter is empty and check none are filtered 160 | self.eventWindow.ui.filterLineEdit.setText("") 161 | self.assertEqual(self.eventWindow.filtered_model.rowCount(), len(event_list)) 162 | 163 | # Set the filter to a unique string and check it returns 1 match 164 | self.eventWindow.ui.filterLineEdit.setText(event_list[0]) 165 | self.assertEqual(self.eventWindow.filtered_model.rowCount(), 1) 166 | 167 | # Set the filter to a non-unique string and check it returns 2 matches 168 | self.eventWindow.ui.filterLineEdit.setText(event_list[2]) 169 | self.assertEqual(self.eventWindow.filtered_model.rowCount(), 2) 170 | 171 | 172 | if __name__ == '__main__': 173 | unittest.main() 174 | -------------------------------------------------------------------------------- /mpfmonitor/tests/test_inspector.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from mpfmonitor.core.inspector import * 4 | from unittest.mock import MagicMock 5 | 6 | 7 | class TestableInspectorNoGUI(InspectorWindow): 8 | def __init__(self, mpfmon_mock=None, logger=False): 9 | if mpfmon_mock is not None: 10 | self.mpfmon = mpfmon_mock 11 | 12 | # super().__init__() 13 | self.ui = None 14 | 15 | # Call logger=True if "RuntimeError: super-class __init__()" starts failing tests. 16 | if logger: 17 | self.log = logging.getLogger('Core') 18 | 19 | # self.draw_ui() 20 | # self.attach_signals() 21 | 22 | 23 | class InspectorMode(unittest.TestCase): 24 | def test_toggle_inspector_mode_on(self): 25 | mock_mpfmon = MagicMock() 26 | mock_mpfmon.inspector_enabled = False 27 | inspector = TestableInspectorNoGUI(mpfmon_mock=mock_mpfmon, logger=True) 28 | 29 | # Register the callback to set the inspector value as a mock 30 | inspector.register_set_inspector_val_cb(MagicMock()) 31 | 32 | inspector.toggle_inspector_mode() 33 | 34 | # Test that the previously registered mock is called. 35 | inspector.set_inspector_val_cb.assert_called_once_with(True) 36 | 37 | def test_toggle_inspector_mode_off(self): 38 | mock_mpfmon = MagicMock() 39 | mock_mpfmon.inspector_enabled = True 40 | inspector = TestableInspectorNoGUI(mpfmon_mock=mock_mpfmon, logger=True) 41 | 42 | # Register the callback to set the inspector value as a mock 43 | inspector.register_set_inspector_val_cb(MagicMock()) 44 | 45 | # clear_last_selected_device should be called as a result of toggling on -> off 46 | inspector.clear_last_selected_device = MagicMock() 47 | 48 | inspector.toggle_inspector_mode() 49 | 50 | # Test that the previously registered mock is called. 51 | inspector.set_inspector_val_cb.assert_called_once_with(False) 52 | 53 | # Test clear_last_selected_device was actually called 54 | inspector.clear_last_selected_device.assert_called_once() 55 | 56 | def test_cb_register(self): 57 | inspector = TestableInspectorNoGUI() 58 | inspector.registered_inspector_cb = False 59 | 60 | callback = MagicMock() 61 | inspector.register_set_inspector_val_cb(cb=callback) 62 | 63 | # Check that the mocked callback is registered properly 64 | inspector.set_inspector_val_cb(True) 65 | 66 | self.assertTrue(inspector.registered_inspector_cb) 67 | inspector.set_inspector_val_cb.assert_called_once_with(True) 68 | 69 | class InspectorDeviceManipulation(unittest.TestCase): 70 | 71 | def test_update_last_selected(self): 72 | inspector = TestableInspectorNoGUI() 73 | # Mock ui to check that the ui is updated 74 | inspector.ui = MagicMock() 75 | 76 | # Mock widget to pass into the pf_widget parameter 77 | mock_widget = MagicMock() 78 | 79 | # Set the mock widget's name and size. 80 | mock_widget.name.__str__.return_value = 'LastName' 81 | # Return value doesn't quite work here. Just load in a float. 82 | widget_size = float(0.10) 83 | mock_widget.size = widget_size 84 | 85 | inspector.update_last_selected(pf_widget=mock_widget) 86 | 87 | # Check that the name is called. 88 | mock_widget.name.__str__.assert_called_once() 89 | 90 | inspector.ui.device_group_box.setTitle.assert_called_once_with('"LastName" Size:') 91 | inspector.ui.size_slider.setValue.assert_called_once_with(widget_size * 100) 92 | inspector.ui.size_spinbox.setValue.assert_called_once_with(widget_size) 93 | 94 | def test_delete_last_device(self): 95 | inspector = TestableInspectorNoGUI() 96 | 97 | inspector.last_pf_widget = MagicMock() 98 | inspector.clear_last_selected_device = MagicMock() 99 | 100 | inspector.delete_last_device() 101 | 102 | inspector.last_pf_widget.destroy.assert_called_once() 103 | inspector.clear_last_selected_device.assert_called_once() 104 | 105 | 106 | class InspectorDeviceResizing(unittest.TestCase): 107 | 108 | def test_resize_default_device_default_no_save(self): 109 | mock_mpfmon = MagicMock() 110 | inspector = TestableInspectorNoGUI(mpfmon_mock=mock_mpfmon) 111 | 112 | size = float(0.07) 113 | 114 | inspector.last_pf_widget = None 115 | inspector.update_last_device(new_size=size, save=False) 116 | 117 | # self.assertEqual(mock_mpfmon.pf_device_size, size) 118 | 119 | def test_resize_default_device_default_save(self): 120 | mock_mpfmon = MagicMock() 121 | inspector = TestableInspectorNoGUI(mpfmon_mock=mock_mpfmon) 122 | 123 | size = float(0.07) 124 | 125 | inspector.last_pf_widget = None 126 | inspector.resize_all_devices = MagicMock() 127 | inspector.update_last_device(new_size=size, save=True) 128 | 129 | # self.assertEqual(mock_mpfmon.pf_device_size, size) 130 | inspector.resize_all_devices.assert_called_once() 131 | mock_mpfmon.view.resizeEvent.assert_called_once() # Re draw the playfiled 132 | mock_mpfmon.save_config.assert_called_once() # Save the config with new default to disk 133 | 134 | def test_resize_last_device_default_no_save(self): 135 | mock_mpfmon = MagicMock() 136 | inspector = TestableInspectorNoGUI(mpfmon_mock=mock_mpfmon) 137 | 138 | size = float(0.07) 139 | 140 | inspector.last_pf_widget = MagicMock() 141 | inspector.update_last_device(new_size=size, save=False) 142 | 143 | inspector.last_pf_widget.set_size.assert_called_once_with(size) 144 | inspector.last_pf_widget.update_pos.assert_called_once_with(save=False) 145 | 146 | mock_mpfmon.view.resizeEvent.assert_called_once() # Re draw the playfield 147 | 148 | def test_resize_last_device_default_save(self): 149 | mock_mpfmon = MagicMock() 150 | inspector = TestableInspectorNoGUI(mpfmon_mock=mock_mpfmon) 151 | 152 | size = float(0.07) 153 | 154 | inspector.last_pf_widget = MagicMock() 155 | inspector.update_last_device(new_size=size, save=True) 156 | 157 | inspector.last_pf_widget.set_size.assert_called_once_with(size) 158 | inspector.last_pf_widget.update_pos.assert_called_once_with(save=True) 159 | 160 | mock_mpfmon.view.resizeEvent.assert_called_once() # Re draw the playfield 161 | 162 | 163 | class InspectorDeviceRotation(unittest.TestCase): 164 | 165 | def test_rotate_device_without_save(self): 166 | mock_mpfmon = MagicMock() 167 | inspector = TestableInspectorNoGUI(mpfmon_mock=mock_mpfmon) 168 | 169 | rotation = 90 170 | 171 | inspector.last_pf_widget = MagicMock() 172 | inspector.update_last_device(rotation=rotation, save=False) 173 | inspector.last_pf_widget.set_rotation.assert_called_once_with(rotation) 174 | inspector.last_pf_widget.update_pos.assert_called_once_with(save=False) 175 | 176 | def test_rotate_device_with_save(self): 177 | mock_mpfmon = MagicMock() 178 | inspector = TestableInspectorNoGUI(mpfmon_mock=mock_mpfmon) 179 | 180 | rotation = 90 181 | 182 | inspector.last_pf_widget = MagicMock() 183 | inspector.update_last_device(rotation=rotation, save=True) 184 | inspector.last_pf_widget.set_rotation.assert_called_once_with(rotation) 185 | inspector.last_pf_widget.update_pos.assert_called_once_with(save=True) 186 | 187 | 188 | class InspectorDeviceShape(unittest.TestCase): 189 | from mpfmonitor.core.playfield import Shape 190 | 191 | def test_device_shape_without_save(self): 192 | mock_mpfmon = MagicMock() 193 | inspector = TestableInspectorNoGUI(mpfmon_mock=mock_mpfmon) 194 | 195 | shape = Shape.TRIANGLE 196 | 197 | inspector.last_pf_widget = MagicMock() 198 | inspector.update_last_device(shape=shape, save=False) 199 | inspector.last_pf_widget.set_shape_type.assert_called_once_with(shape_type=shape) 200 | inspector.last_pf_widget.update_pos.assert_called_once_with(save=False) 201 | 202 | def test_device_shape_with_save(self): 203 | mock_mpfmon = MagicMock() 204 | inspector = TestableInspectorNoGUI(mpfmon_mock=mock_mpfmon) 205 | 206 | shape = Shape.TRIANGLE 207 | 208 | inspector.last_pf_widget = MagicMock() 209 | inspector.update_last_device(shape=shape, save=True) 210 | inspector.last_pf_widget.set_shape_type.assert_called_once_with(shape_type=shape) 211 | inspector.last_pf_widget.update_pos.assert_called_once_with(save=True) 212 | 213 | 214 | if __name__ == '__main__': 215 | unittest.main() 216 | -------------------------------------------------------------------------------- /mpfmonitor/tests/test_playfield.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from mpfmonitor.core.playfield import * 3 | from unittest.mock import MagicMock 4 | 5 | class TestablePfWidgetNonDrawn(PfWidget): 6 | def __init__(self, mpfmon_mock=None): 7 | 8 | if mpfmon_mock is not None: 9 | self.mpfmon = mpfmon_mock 10 | 11 | """ 12 | __init__ of PfWidget: 13 | 14 | 15 | def __init__(self, mpfmon, widget, device_type, device_name, x, y, 16 | size=None, rotation=0, shape=Shape.DEFAULT, save=True): 17 | super().__init__() 18 | 19 | self.widget = widget 20 | self.mpfmon = mpfmon 21 | self.name = device_name 22 | self.move_in_progress = True 23 | self.device_type = device_type 24 | self.set_size(size=size) 25 | self.shape_type = shape 26 | self.angle = rotation 27 | 28 | self.setToolTip('{}: {}'.format(self.device_type, self.name)) 29 | self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton | Qt.MouseButton.RightButton) 30 | self.setPos(x, y) 31 | self.update_pos(save) 32 | self.click_start = 0 33 | self.release_switch = False 34 | 35 | self.log = logging.getLogger('Core') 36 | 37 | old_widget_exists = widget.set_change_callback(self.notify) 38 | 39 | if old_widget_exists: 40 | self.log.debug("Previous widget exists.") 41 | old_widget_exists(destroy=True) 42 | 43 | """ 44 | 45 | 46 | class TestPfWidgetParameters(unittest.TestCase): 47 | 48 | def setUp(self): 49 | self.widget = TestablePfWidgetNonDrawn() 50 | 51 | def test_shape_set_valid(self): 52 | shape_to_be_set = Shape.TRIANGLE 53 | self.widget.set_shape_type(shape_to_be_set) 54 | 55 | self.assertEqual(self.widget.shape_type, shape_to_be_set) 56 | 57 | def test_shape_set_invalid(self): 58 | widget = TestablePfWidgetNonDrawn() 59 | 60 | shape_to_be_set = "Not_A_Shape" 61 | self.widget.set_shape_type(shape_to_be_set) 62 | 63 | self.assertEqual(self.widget.shape_type, Shape.DEFAULT) 64 | 65 | def test_rotation_set_valid(self): 66 | rotation_to_be_set = 42 67 | self.widget.set_rotation(rotation_to_be_set) 68 | 69 | self.assertEqual(self.widget.angle, rotation_to_be_set) 70 | 71 | def test_rotation_set_invalid(self): 72 | rotation_to_be_set = 451 73 | self.widget.set_rotation(rotation_to_be_set) 74 | 75 | expected_angle = rotation_to_be_set % 360 76 | 77 | self.assertEqual(self.widget.angle, expected_angle) 78 | 79 | def test_size_set_default(self): 80 | self.widget.mpfmon = MagicMock() 81 | default_size = 0.07 82 | scene_width = 1.00 83 | 84 | self.widget.mpfmon.pf_device_size = default_size 85 | self.widget.mpfmon.scene.width.return_value = scene_width 86 | 87 | self.widget.set_size() 88 | 89 | self.assertEqual(self.widget.size, default_size) 90 | self.assertEqual(self.widget.device_size, default_size * scene_width) 91 | 92 | def test_size_set_valid(self): 93 | self.widget.mpfmon = MagicMock() 94 | scene_width = 1.00 95 | 96 | self.widget.mpfmon.scene.width.return_value = scene_width 97 | 98 | size_to_be_set = 0.07 99 | 100 | self.widget.set_size(size=size_to_be_set) 101 | 102 | self.assertEqual(self.widget.size, size_to_be_set) 103 | self.assertEqual(self.widget.device_size, size_to_be_set * scene_width) 104 | 105 | 106 | class TestPfWidgetResizeToDefault(unittest.TestCase): 107 | 108 | def setUp(self): 109 | self.mock_mpfmon = MagicMock() 110 | 111 | self.widget = TestablePfWidgetNonDrawn(mpfmon_mock=self.mock_mpfmon) 112 | 113 | self.widget.device_type = MagicMock() 114 | self.widget.name = MagicMock() 115 | self.widget.set_size = MagicMock() 116 | self.widget.update_pos = MagicMock() 117 | 118 | self.config = MagicMock() 119 | self.mock_mpfmon.config[self.widget.device_type].get.return_value = self.config 120 | self.config.get.return_value = None 121 | 122 | """ 123 | def resize_to_default(self, force=False): 124 | device_config = self.mpfmon.config[self.device_type].get(self.name, None) 125 | 126 | if force: 127 | device_config.pop('size', None) # Delete saved size info, None is incase key doesn't exist (popped twice) 128 | 129 | device_size = device_config.get('size', None) 130 | 131 | if device_size is not None: 132 | # Do not change the size if it's already set 133 | pass 134 | elif device_config is not None: 135 | self.set_size() 136 | 137 | self.update_pos(save=False) # Do not save at this point. Let it be saved elsewhere. This reduces writes.""" 138 | 139 | def test_size_resize_to_default(self): 140 | self.widget.resize_to_default() 141 | 142 | self.mock_mpfmon.config[self.widget.device_type].get.assert_called_once_with(self.widget.name, None) 143 | self.widget.set_size.assert_called_once() 144 | self.widget.update_pos.assert_called_once_with(save=False) 145 | 146 | def test_size_resize_to_default_with_force(self): 147 | self.widget.resize_to_default(force=True) 148 | 149 | self.mock_mpfmon.config[self.widget.device_type].get.assert_called_once_with(self.widget.name, None) 150 | self.widget.set_size.assert_called_once() 151 | self.widget.update_pos.assert_called_once_with(save=False) 152 | self.config.pop.assert_called_once_with('size', None) 153 | 154 | 155 | class TestPfWidgetColorFuncs(unittest.TestCase): 156 | 157 | def setUp(self): 158 | self.widget = TestablePfWidgetNonDrawn() 159 | 160 | def test_color_gamma(self): 161 | color_in = [0, 128, 255] 162 | expected_color_out = [0, 203, 255] # Manually calculated 128 -> 203 163 | 164 | mock_widget = DeviceNode() 165 | mock_widget.setData({"color": color_in}) 166 | mock_widget.setType('light') 167 | color_out = mock_widget._calculate_color_gamma_correction(color=color_in) 168 | 169 | self.assertEqual(color_out, expected_color_out, 'Gamma does not match expected value') 170 | 171 | def test_colored_brush_light(self): 172 | color_in = [0, 128, 255] 173 | expected_color_out = [0, 203, 255] # Manually calculated 128 -> 203 174 | device_type = 'light' 175 | mock_widget = DeviceNode() 176 | mock_widget.setData({"color": color_in}) 177 | mock_widget.setType(device_type) 178 | 179 | expected_q_brush_out = QBrush(QColor(*expected_color_out), Qt.BrushStyle.SolidPattern) 180 | q_brush_out = mock_widget.get_colored_brush() 181 | 182 | self.assertEqual(q_brush_out, expected_q_brush_out, 'Brush is not returning correct value') 183 | 184 | def test_colored_brush_switch_off(self): 185 | device_type = 'switch' 186 | expected_color_out = [0, 0, 0] 187 | mock_widget = DeviceNode() 188 | mock_widget.setData({'state': False}) 189 | mock_widget.setType(device_type) 190 | 191 | expected_q_brush_out = QBrush(QColor(*expected_color_out), Qt.BrushStyle.SolidPattern) 192 | q_brush_out = mock_widget.get_colored_brush() 193 | 194 | self.assertEqual(q_brush_out, expected_q_brush_out, 'Brush is not returning correct value') 195 | 196 | def test_colored_brush_switch_on(self): 197 | device_type = 'switch' 198 | expected_color_out = [0, 255, 0] 199 | mock_widget = DeviceNode() 200 | mock_widget.setData({'state': True}) 201 | mock_widget.setType(device_type) 202 | 203 | expected_q_brush_out = QBrush(QColor(*expected_color_out), Qt.BrushStyle.SolidPattern) 204 | q_brush_out = mock_widget.get_colored_brush() 205 | 206 | self.assertEqual(q_brush_out, expected_q_brush_out, 'Brush is not returning correct value') 207 | 208 | 209 | class TestPfWidgetGetAndDestroy(unittest.TestCase): 210 | 211 | def setUp(self): 212 | self.widget = TestablePfWidgetNonDrawn(mpfmon_mock=MagicMock()) 213 | 214 | def test_delete_from_config(self): 215 | device_type = MagicMock() 216 | self.widget.device_type = device_type 217 | 218 | name = "delete_test" 219 | self.widget.name = name 220 | 221 | self.widget.delete_from_config() 222 | 223 | self.widget.mpfmon.config[device_type].pop.assert_called_once_with(name) 224 | self.widget.mpfmon.save_config.assert_called_once() 225 | 226 | def test_send_to_inspector_window(self): 227 | self.widget.send_to_inspector_window() 228 | self.widget.mpfmon.inspector_window_last_selected_cb.assert_called_once_with(pf_widget=self.widget) 229 | -------------------------------------------------------------------------------- /mpfmonitor/core/inspector.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from PyQt6 import uic 5 | # will change these to specific imports once code is more final 6 | from PyQt6.QtCore import * 7 | from PyQt6.QtGui import * 8 | from PyQt6.QtWidgets import * 9 | 10 | from mpfmonitor._version import __version__, __bcp_version__ 11 | from mpfmonitor.core.playfield import Shape 12 | 13 | 14 | class InspectorWindow(QWidget): 15 | 16 | def __init__(self, mpfmon): 17 | self.mpfmon = mpfmon 18 | super().__init__() 19 | self.ui = None 20 | 21 | self.log = logging.getLogger('Core') 22 | 23 | self.draw_ui() 24 | self.attach_signals() 25 | 26 | self.enable_non_default_widgets(enabled=False) 27 | 28 | self.last_pf_widget = None 29 | 30 | def draw_ui(self): 31 | # Load ui file from ./ui/ 32 | ui_path = os.path.join(os.path.dirname(__file__), "ui", "inspector.ui") 33 | self.ui = uic.loadUi(ui_path, self) 34 | 35 | self.ui.setWindowTitle('Inspector') 36 | 37 | self.ui.move(self.mpfmon.local_settings.value('windows/inspector/pos', 38 | QPoint(1100, 465))) 39 | self.ui.resize(self.mpfmon.local_settings.value('windows/inspector/size', 40 | QSize(300, 340))) 41 | 42 | mpf_monitor_version = "MPF Monitor Version: {}".format(__version__) 43 | self.ui.mpf_monitor_version.setText(mpf_monitor_version) 44 | bcp_required_version = "BCP Version Required: {} or greater".format(__bcp_version__) 45 | self.ui.bcp_required_version.setText(bcp_required_version) 46 | 47 | def attach_signals(self): 48 | self.attach_inspector_tab_signals() 49 | self.attach_monitor_tab_signals() 50 | 51 | def attach_inspector_tab_signals(self): 52 | self.ui.toggle_inspector_button.clicked.connect(self.toggle_inspector_mode) 53 | 54 | self.ui.shape_combo_box.currentIndexChanged.connect(self.shape_combobox_changed) 55 | self.ui.rotationDial.valueChanged.connect(self.dial_changed) 56 | 57 | self.ui.size_slider.valueChanged.connect(self.slider_drag) # Doesn't save value, just for live preview 58 | self.ui.size_slider.sliderReleased.connect(self.slider_changed) # Saves value on release 59 | self.ui.size_spinbox.valueChanged.connect(self.spinbox_changed) 60 | 61 | self.ui.reset_to_defaults_button.clicked.connect(self.reset_defaults_last_device) 62 | self.ui.delete_last_device_button.clicked.connect(self.delete_last_device) 63 | 64 | def attach_monitor_tab_signals(self): 65 | self.ui.toggle_device_win_button.setChecked(self.mpfmon.toggle_device_window_action.isChecked()) 66 | self.ui.toggle_device_win_button.stateChanged.connect(self.mpfmon.toggle_device_window) 67 | 68 | self.ui.toggle_event_win_button.setChecked(self.mpfmon.toggle_event_window_action.isChecked()) 69 | self.ui.toggle_event_win_button.stateChanged.connect(self.mpfmon.toggle_event_window) 70 | 71 | self.ui.toggle_pf_win_button.setChecked(self.mpfmon.toggle_pf_window_action.isChecked()) 72 | self.ui.toggle_pf_win_button.stateChanged.connect(self.mpfmon.toggle_pf_window) 73 | 74 | self.ui.toggle_mode_win_button.setChecked(self.mpfmon.toggle_mode_window_action.isChecked()) 75 | self.ui.toggle_mode_win_button.stateChanged.connect(self.mpfmon.toggle_mode_window) 76 | 77 | self.ui.toggle_variables_win_button.setChecked(self.mpfmon.toggle_variables_window_action.isChecked()) 78 | self.ui.toggle_variables_win_button.stateChanged.connect(self.mpfmon.toggle_variables_window) 79 | 80 | self.ui.exit_on_close_button.setChecked(self.mpfmon.get_local_settings_bool('settings/exit-on-close')) 81 | self.ui.exit_on_close_button.stateChanged.connect(self.mpfmon.toggle_exit_on_close) 82 | 83 | def toggle_inspector_mode(self): 84 | inspector_enabled = not self.mpfmon.inspector_enabled 85 | if self.registered_inspector_cb: 86 | self.set_inspector_val_cb(inspector_enabled) 87 | if inspector_enabled: 88 | self.log.debug('Inspector mode toggled ON') 89 | else: 90 | self.log.debug('Inspector mode toggled OFF') 91 | self.enable_non_default_widgets(enabled=False) 92 | self.clear_last_selected_device() 93 | 94 | def register_set_inspector_val_cb(self, cb): 95 | self.registered_inspector_cb = True 96 | self.set_inspector_val_cb = cb 97 | 98 | def update_last_selected(self, pf_widget=None): 99 | if pf_widget is not None: 100 | self.enable_non_default_widgets(enabled=True) 101 | 102 | self.last_pf_widget = pf_widget 103 | 104 | # Update the label to show name of last selected 105 | text = '"' + str(self.last_pf_widget.name) + '" Size:' 106 | self.ui.device_group_box.setTitle(text) 107 | 108 | # Update the size slider and spinbox 109 | self.ui.size_slider.setValue(int(self.last_pf_widget.size * 100)) 110 | self.ui.size_spinbox.setValue(self.last_pf_widget.size) 111 | 112 | # Update the shape combo box 113 | self.ui.shape_combo_box.setCurrentIndex(self.last_pf_widget.shape_type.value) 114 | 115 | # Update the rotation dial 116 | rotation = int(self.last_pf_widget.angle / 10) + 18 117 | self.ui.rotationDial.setValue(rotation) 118 | 119 | def slider_drag(self): 120 | # For live preview 121 | new_size = self.ui.size_slider.value() / 100 # convert from int to float 122 | self.update_last_device(new_size=new_size, save=False) 123 | 124 | def slider_changed(self): 125 | new_size = self.ui.size_slider.value() / 100 # convert from int to float 126 | # Update spinbox value 127 | self.ui.size_spinbox.setValue(new_size) 128 | 129 | # Don't need to call resize_last_device because updating the spinbox takes care of it 130 | # self.resize_last_device(new_size=new_size) 131 | 132 | def spinbox_changed(self): 133 | new_size = self.ui.size_spinbox.value() 134 | # Update slider value 135 | self.ui.size_slider.setValue(int(new_size*100)) 136 | 137 | self.update_last_device(new_size=new_size) 138 | 139 | def dial_changed(self): 140 | rot_value = self.ui.rotationDial.value() * 10 141 | # Offset the dial by 180 142 | rot_value = (rot_value - 180) % 360 143 | # self.rotate_last_device(rotation=rot_value, save=False) 144 | self.update_last_device(rotation=rot_value, save=True) 145 | 146 | def shape_combobox_changed(self): 147 | shape_index = self.ui.shape_combo_box.currentIndex() 148 | self.update_last_device(shape=Shape(shape_index), save=True) 149 | 150 | def clear_last_selected_device(self): 151 | # Must be called AFTER spinbox valueChanged is set. Otherwise slider will not follow 152 | 153 | # self.last_selected_label.setText("Default Device Size:") 154 | self.ui.device_group_box.setTitle("Default Device:") 155 | self.last_pf_widget = None 156 | self.ui.size_spinbox.setValue(self.mpfmon.pf_device_size) # Reset the value to the stored default. 157 | self.enable_non_default_widgets(enabled=False) 158 | 159 | 160 | def enable_non_default_widgets(self, enabled=False): 161 | if self.ui is not None: 162 | self.ui.rotationDial.setEnabled(enabled) 163 | self.ui.shape_combo_box.setEnabled(enabled) 164 | 165 | 166 | def update_last_device(self, new_size=None, rotation=None, shape=None, save=True): 167 | # Check that there is a last widget 168 | if self.last_pf_widget is not None: 169 | 170 | update_and_resize = False 171 | 172 | if new_size is not None: 173 | new_size = round(new_size, 3) 174 | 175 | self.last_pf_widget.set_size(new_size) 176 | update_and_resize = True 177 | 178 | if rotation is not None: 179 | self.last_pf_widget.set_rotation(rotation) 180 | update_and_resize = True 181 | 182 | if shape is not None: 183 | self.last_pf_widget.set_shape_type(shape_type=shape) 184 | update_and_resize = True 185 | 186 | if update_and_resize: 187 | self.last_pf_widget.update_pos(save=save) 188 | self.mpfmon.view.resizeEvent() 189 | 190 | else: 191 | if new_size is not None: 192 | self.mpfmon.pf_device_size = new_size 193 | self.mpfmon.config["device_size"] = new_size 194 | 195 | if save: 196 | self.resize_all_devices() # Apply new sizes to all devices without default sizes 197 | self.mpfmon.view.resizeEvent() # Re draw the playfield 198 | self.mpfmon.save_config() # Save the config with new default to disk 199 | 200 | 201 | def delete_last_device(self): 202 | if self.last_pf_widget is not None: 203 | self.last_pf_widget.destroy() 204 | self.clear_last_selected_device() 205 | else: 206 | self.log.info("No device selected to delete") 207 | 208 | def reset_defaults_last_device(self): 209 | if self.last_pf_widget is not None: 210 | 211 | # Redraw the device and save changes 212 | default_size = self.mpfmon.pf_device_size 213 | self.update_last_device(new_size=default_size, shape=Shape.DEFAULT, 214 | rotation=0, save=True) 215 | 216 | self.ui.size_spinbox.setValue(default_size) 217 | self.ui.rotationDial.setValue(18) 218 | self.ui.shape_combo_box.setCurrentIndex(0) 219 | 220 | 221 | # Update the device info and clear saved size data 222 | self.last_pf_widget.resize_to_default(force=True) 223 | 224 | 225 | # Redraw the device 226 | else: 227 | self.ui.size_spinbox.setValue(0.07) 228 | self.log.info("No device selected to resize") 229 | 230 | def resize_all_devices(self): 231 | for i in self.mpfmon.scene.items(): 232 | try: 233 | i.resize_to_default() 234 | except AttributeError as e: 235 | # Can't resize object. That's ok. 236 | pass 237 | 238 | def register_last_selected_cb(self): 239 | self.mpfmon.inspector_window_last_selected_cb = self.update_last_selected 240 | 241 | 242 | def closeEvent(self, event): 243 | self.mpfmon.write_local_settings() 244 | event.accept() 245 | self.mpfmon.check_if_quit() 246 | -------------------------------------------------------------------------------- /mpfmonitor/core/bcp_client.py: -------------------------------------------------------------------------------- 1 | """BCP Server interface for the MPF Media Controller""" 2 | 3 | import logging 4 | import queue 5 | import socket 6 | import threading 7 | import os 8 | import time 9 | 10 | import select 11 | 12 | from datetime import datetime 13 | import math 14 | 15 | import mpf.core.bcp.bcp_socket_client as bcp 16 | from PyQt6.QtCore import QTimer 17 | 18 | 19 | class BCPClient(object): 20 | 21 | def __init__(self, mpfmon, receiving_queue, sending_queue, 22 | interface='localhost', port=5051, simulate=False, cache=False): 23 | 24 | self.mpfmon = mpfmon 25 | self.log = logging.getLogger('BCP Client') 26 | self.interface = interface 27 | self.port = port 28 | self.receive_queue = receiving_queue 29 | self.sending_queue = sending_queue 30 | self.connected = False 31 | self.socket = None 32 | self.sending_thread = None 33 | self.receive_thread = None 34 | self.connection_thread_running = False 35 | self.done = False 36 | self.last_time = datetime.now() 37 | 38 | self.simulate = simulate 39 | self.caching_enabled = cache 40 | 41 | try: 42 | self.cache_file_location = os.path.join(self.mpfmon.machine_path, "monitor", "cache.txt") 43 | except FileNotFoundError: 44 | self.simulate = False 45 | self.caching_enabled = False 46 | 47 | self.mpfmon.log.info('Looking for MPF at %s:%s', self.interface, self.port) 48 | 49 | self.simulator_timer = QTimer(self.mpfmon.device_window) 50 | 51 | self.simulator_messages = [] 52 | self.simulator_msg_timer = [] 53 | self.enable_simulator(enable=self.simulate) 54 | 55 | def start_connect_thread(self): 56 | """Starts the connection process in a separate thread.""" 57 | if not self.connection_thread_running: 58 | self.connection_thread_running = True 59 | self.connection_thread = threading.Thread(target=self.connection_thread, daemon=True) 60 | self.connection_thread.start() 61 | 62 | def connection_thread(self): 63 | """The main loop for the BCP client running in a separate thread.""" 64 | while not self.mpfmon.thread_stopper.is_set(): 65 | if not self.connected: 66 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 67 | self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 68 | try: 69 | self.socket.connect((self.interface, int(self.port))) 70 | self.socket.settimeout(0.1) # Set a small timeout for non-blocking operations 71 | self.connected = True 72 | self.log.info("Connected to MPF.") 73 | except socket.error as e: 74 | self.connected = False 75 | time.sleep(1) 76 | continue 77 | 78 | if self.connected: 79 | if self.create_socket_threads(): 80 | self.start_monitoring() 81 | else: 82 | time.sleep(1) 83 | 84 | 85 | def register_timer(self): 86 | if self.simulate: 87 | self.simulator_init() 88 | self.simulator_timer.setInterval(100) 89 | self.simulator_timer.timeout.connect(self.simulate_received) 90 | self.simulator_timer.start() 91 | else: 92 | self.simulator_timer.stop() 93 | self.start_connect_thread() 94 | 95 | def enable_simulator(self, enable=True): 96 | if enable: 97 | self.simulate = True 98 | if self.caching_enabled: 99 | try: 100 | self.cache_file = open(self.cache_file_location, "r") 101 | except FileNotFoundError: 102 | self.log.warn("Caching enabled but no cache file found.") 103 | self.simulate = False 104 | self.caching_enabled = False 105 | self.enable_simulator(False) 106 | else: 107 | self.simulate = False 108 | if self.caching_enabled: 109 | self.cache_file = open(self.cache_file_location, "w") 110 | 111 | self.start_time = datetime.now() 112 | self.register_timer() 113 | 114 | def start_monitoring(self): 115 | self.sending_queue.put('monitor_start?category=devices') 116 | self.sending_queue.put('monitor_start?category=events') 117 | self.sending_queue.put('monitor_start?category=modes') 118 | self.sending_queue.put('monitor_start?category=machine_vars') 119 | self.sending_queue.put('monitor_start?category=player_vars') 120 | 121 | def create_socket_threads(self): 122 | """Creates and starts the sending and receiving threads for the BCP 123 | socket. 124 | Returns: 125 | True if the socket exists and the threads were started. False if 126 | not. 127 | """ 128 | 129 | if self.socket: 130 | 131 | self.receive_thread = threading.Thread(target=self.receive_loop) 132 | # self.receive_thread.daemon = True 133 | self.receive_thread.start() 134 | 135 | self.sending_thread = threading.Thread(target=self.sending_loop) 136 | # self.sending_thread.daemon = True 137 | self.sending_thread.start() 138 | 139 | return True 140 | 141 | else: 142 | return False 143 | 144 | def receive_loop(self): 145 | """The socket thread's run loop.""" 146 | socket_chars = b'' 147 | while self.connected and not self.mpfmon.thread_stopper.is_set(): 148 | try: 149 | ready = select.select([self.socket], [], [], 1) 150 | if ready[0]: 151 | data_read = self.socket.recv(8192) 152 | if data_read: 153 | socket_chars += data_read 154 | commands = socket_chars.split(b"\n") 155 | 156 | # keep last incomplete command 157 | socket_chars = commands.pop() 158 | 159 | # process all complete commands 160 | for cmd in commands: 161 | if cmd: 162 | self.process_received_message(cmd.decode()) 163 | else: 164 | # no bytes -> socket closed 165 | break 166 | 167 | except socket.timeout: 168 | pass 169 | 170 | except OSError: 171 | break 172 | 173 | self.connected = False 174 | 175 | def disconnect(self): 176 | if not self.connected: 177 | self.log.info("Disconnecting from BCP") 178 | self.sending_queue.put('goodbye', None) 179 | 180 | def close(self): 181 | try: 182 | self.socket.shutdown(socket.SHUT_RDWR) 183 | self.socket.close() 184 | 185 | except (OSError, AttributeError): 186 | pass 187 | 188 | if self.caching_enabled: 189 | self.cache_file.close() 190 | 191 | self.socket = None 192 | self.connected = False 193 | 194 | with self.receive_queue.mutex: 195 | self.receive_queue.queue.clear() 196 | 197 | with self.sending_queue.mutex: 198 | self.sending_queue.queue.clear() 199 | 200 | def sending_loop(self): 201 | while self.connected and not self.mpfmon.thread_stopper.is_set(): 202 | try: 203 | msg = self.sending_queue.get(block=True, timeout=1) 204 | except queue.Empty: 205 | if self.mpfmon.thread_stopper.is_set(): 206 | return 207 | 208 | else: 209 | continue 210 | 211 | self.socket.sendall(('{}\n'.format(msg)).encode('utf-8')) 212 | 213 | self.connected = False 214 | 215 | def process_received_message(self, message): 216 | """Puts a received BCP message into the receiving queue. 217 | 218 | Args: 219 | message: The incoming BCP message 220 | 221 | """ 222 | self.log.debug('Received "%s"', message) 223 | if self.caching_enabled and not self.simulate: 224 | time = datetime.now() - self.last_time 225 | message_tmr = math.floor(time.microseconds / 1000) 226 | self.last_time = datetime.now() 227 | self.cache_file.write(str(message_tmr) + "," + message + "\n") 228 | 229 | try: 230 | cmd, kwargs = bcp.decode_command_string(message) 231 | self.receive_queue.put((cmd, kwargs)) 232 | except ValueError: 233 | self.log.error("DECODE BCP ERROR. Message: %s", message) 234 | raise 235 | 236 | def send(self, bcp_command, **kwargs): 237 | self.sending_queue.put(bcp.encode_command_string(bcp_command, 238 | **kwargs)) 239 | 240 | def simulator_init(self): 241 | if self.caching_enabled: 242 | self.simulator_msg_timer.append(int(0)) 243 | for message in self.cache_file: 244 | message_tmr = int(message.split(',', 1)[0]) 245 | message_str = message.split(',', 1)[1] 246 | 247 | self.simulator_msg_timer.append(message_tmr) 248 | self.simulator_messages.append(message_str) 249 | else: 250 | messages = [ 251 | 'device?json={"type": "switch", "name": "s_start", "changes": false, "state": {"state": 0, "recycle_jitter_count": 0}}', 252 | 'device?json={"type": "switch", "name": "s_trough_1", "changes": false, "state": {"state": 1, "recycle_jitter_count": 0}}', 253 | 'device?json={"type": "light", "name": "l_shoot_again", "changes": ["color", [255, 255, 255], [0, 0, 0]], "state": {"color": [0, 0, 0]}}', 254 | 'device?json={"type": "light", "name": "l_ball_save", "changes": ["color", [0, 0, 0], [255, 255, 255]], "state": {"color": [255, 255, 255]}}', 255 | ] 256 | self.simulator_messages = messages 257 | self.simulator_msg_timer = [100, 100, 100, 100] 258 | 259 | def simulate_received(self): 260 | if len(self.simulator_messages) > 0: 261 | next_message = self.simulator_messages.pop(0) 262 | timer = self.simulator_msg_timer.pop(0) 263 | self.simulator_timer.setInterval(timer) 264 | self.process_received_message(next_message) 265 | else: 266 | self.simulator_timer.stop() 267 | self.log.info("End of cached file reached.") 268 | -------------------------------------------------------------------------------- /mpfmonitor/core/ui/inspector.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Form 4 | 5 | 6 | 7 | 0 8 | 0 9 | 342 10 | 384 11 | 12 | 13 | 14 | Form 15 | 16 | 17 | 18 | 8 19 | 20 | 21 | 8 22 | 23 | 24 | 8 25 | 26 | 27 | 28 | 29 | QTabWidget::North 30 | 31 | 32 | 1 33 | 34 | 35 | true 36 | 37 | 38 | true 39 | 40 | 41 | true 42 | 43 | 44 | 45 | Devices 46 | 47 | 48 | 49 | 10 50 | 51 | 52 | 12 53 | 54 | 55 | 10 56 | 57 | 58 | 5 59 | 60 | 61 | 20 62 | 63 | 64 | 65 | 66 | Default Device: 67 | 68 | 69 | false 70 | 71 | 72 | false 73 | 74 | 75 | 76 | QFormLayout::AllNonFixedFieldsGrow 77 | 78 | 79 | Qt::AlignCenter 80 | 81 | 82 | 83 | 84 | 85 | 100 86 | 0 87 | 88 | 89 | 90 | Shape: 91 | 92 | 93 | Qt::AlignCenter 94 | 95 | 96 | 97 | 98 | 99 | 100 | true 101 | 102 | 103 | 104 | Device Default 105 | 106 | 107 | 108 | 109 | Square 110 | 111 | 112 | 113 | 114 | Rectangle 115 | 116 | 117 | 118 | 119 | Circle 120 | 121 | 122 | 123 | 124 | Triangle 125 | 126 | 127 | 128 | 129 | Arrow 130 | 131 | 132 | 133 | 134 | Flipper 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 0 144 | 50 145 | 146 | 147 | 148 | Rotation: 149 | 150 | 151 | Qt::AlignCenter 152 | 153 | 154 | 155 | 156 | 157 | 158 | true 159 | 160 | 161 | 162 | 50 163 | 50 164 | 165 | 166 | 167 | 36 168 | 169 | 170 | 1 171 | 172 | 173 | 18 174 | 175 | 176 | Qt::Horizontal 177 | 178 | 179 | false 180 | 181 | 182 | false 183 | 184 | 185 | true 186 | 187 | 188 | 6.000000000000000 189 | 190 | 191 | true 192 | 193 | 194 | 195 | 196 | 197 | 198 | Size: 199 | 200 | 201 | Qt::AlignCenter 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 1 211 | 212 | 213 | 60 214 | 215 | 216 | 6 217 | 218 | 219 | Qt::Horizontal 220 | 221 | 222 | QSlider::TicksBelow 223 | 224 | 225 | 5 226 | 227 | 228 | 229 | 230 | 231 | 232 | 0.010000000000000 233 | 234 | 235 | 0.600000000000000 236 | 237 | 238 | 0.010000000000000 239 | 240 | 241 | 0.070000000000000 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | Reset to Defaults 251 | 252 | 253 | 254 | 255 | 256 | 257 | Delete Device 258 | 259 | 260 | false 261 | 262 | 263 | false 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | Toggle Device Inspector 274 | 275 | 276 | true 277 | 278 | 279 | false 280 | 281 | 282 | false 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | Monitor 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | Show device window 299 | 300 | 301 | 302 | 303 | 304 | 305 | Show event window 306 | 307 | 308 | 309 | 310 | 311 | 312 | Show playfield window 313 | 314 | 315 | 316 | 317 | 318 | 319 | Show mode window 320 | 321 | 322 | 323 | 324 | 325 | 326 | Show variables window 327 | 328 | 329 | 330 | 331 | 332 | 333 | Qt::Horizontal 334 | 335 | 336 | 337 | 338 | 339 | 340 | Quit on single window close 341 | 342 | 343 | 344 | 345 | 346 | 347 | About MPF Monitor: 348 | 349 | 350 | 351 | 352 | 353 | BCP Version Required: 354 | 355 | 356 | 357 | 358 | 359 | 360 | MPF Monitor Version: 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | Qt::Vertical 373 | 374 | 375 | 376 | 20 377 | 40 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | -------------------------------------------------------------------------------- /mpfmonitor/core/mpfmon.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import queue 3 | import sys 4 | import os 5 | import time 6 | 7 | # will change these to specific imports once code is more final 8 | from collections import deque 9 | 10 | from PyQt6.QtCore import * 11 | from PyQt6.QtGui import * 12 | from PyQt6.QtWidgets import * 13 | 14 | from ruamel import yaml 15 | 16 | from mpfmonitor.core.devices import * 17 | from mpfmonitor.core.playfield import * 18 | from mpfmonitor.core.bcp_client import BCPClient 19 | from mpfmonitor.core.events import EventWindow 20 | from mpfmonitor.core.modes import ModeWindow 21 | from mpfmonitor.core.inspector import InspectorWindow 22 | from mpfmonitor.core.variables import VariableWindow 23 | 24 | def run(machine_path, thread_stopper, config_file, ip_addr="localhost", port="5051", testing=False): 25 | app = QApplication(sys.argv) 26 | MPFMonitor(app, machine_path, thread_stopper, config_file, ip_addr, port, testing=testing) 27 | app.exec() 28 | 29 | class MPFMonitor(): 30 | def __init__(self, app, machine_path, thread_stopper, config_file, ip_addr=None, port=None, parent=None, testing=False): 31 | 32 | # super().__init__(parent) 33 | 34 | self.log = logging.getLogger('Core') 35 | 36 | sys.excepthook = self.except_hook 37 | 38 | self.bcp_client_connected = False 39 | self.receive_queue = queue.Queue() 40 | self.sending_queue = queue.Queue() 41 | self.crash_queue = queue.Queue() 42 | self.thread_stopper = thread_stopper 43 | self.machine_path = machine_path 44 | self.app = app 45 | self.config = None 46 | self.layout = None 47 | self.mpf_ip_addr = ip_addr 48 | self.mpf_port = port 49 | self.config_file = os.path.join(self.machine_path, "monitor", 50 | config_file) 51 | self.playfield_image_file = os.path.join(self.machine_path, 52 | "monitor", "playfield.jpg") 53 | self.settings_file = os.path.join(self.machine_path, 54 | "monitor", "settings.ini") 55 | 56 | QSettings.setDefaultFormat(QSettings.Format.IniFormat) 57 | self.local_settings = QSettings(self.settings_file, QSettings.Format.IniFormat) 58 | 59 | self.load_config() 60 | 61 | self.device_window = DeviceWindow(self) 62 | 63 | self.pf_device_size = self.config.get("device_size", .02) 64 | if not isinstance(self.pf_device_size, float): # Protect against corrupted device size 65 | self.pf_device_size = .02 66 | 67 | #Command line takes priority over settings file. If command line is None, then read the settings file. 68 | 69 | #if mpf_ip_address is not in the settings file, then localhost will be used 70 | if (self.mpf_ip_addr == None): 71 | self.mpf_ip_addr = self.local_settings.value("settings/mpf-ip-address", "localhost") 72 | 73 | #if mpf_port is not in the settings file, then 5051 will be used 74 | if (self.mpf_port == None): 75 | self.mpf_port = self.local_settings.value("settings/mpf-port", "5051") 76 | 77 | self.bcp = BCPClient(self, self.receive_queue, 78 | self.sending_queue, self.mpf_ip_addr, self.mpf_port, 79 | simulate=testing, cache=False) 80 | 81 | self.tick_timer = QTimer(self.device_window) 82 | self.tick_timer.setInterval(20) 83 | self.tick_timer.timeout.connect(self.tick) 84 | self.tick_timer.start() 85 | 86 | self.toggle_pf_window_action = QAction('&Playfield', self.device_window, 87 | statusTip='Show the playfield window', 88 | triggered=self.toggle_pf_window) 89 | self.toggle_pf_window_action.setCheckable(True) 90 | 91 | self.toggle_device_window_action = QAction('&Devices', self.device_window, 92 | statusTip='Show the device window', 93 | triggered=self.toggle_device_window) 94 | self.toggle_device_window_action.setCheckable(True) 95 | 96 | self.toggle_event_window_action = QAction('&Events', self.device_window, 97 | statusTip='Show the events window', 98 | triggered=self.toggle_event_window) 99 | self.toggle_event_window_action.setCheckable(True) 100 | 101 | self.toggle_mode_window_action = QAction('&Modes', self.device_window, 102 | statusTip='Show the mode window', 103 | triggered=self.toggle_mode_window) 104 | self.toggle_mode_window_action.setCheckable(True) 105 | 106 | self.toggle_variables_window_action = QAction('&Variables', self.device_window, 107 | statusTip='Show the variables window', 108 | triggered=self.toggle_variables_window) 109 | self.toggle_variables_window_action.setCheckable(True) 110 | 111 | self.scene = QGraphicsScene() 112 | 113 | self.pf = PfPixmapItem(QPixmap(self.playfield_image_file), self) 114 | self.scene.addItem(self.pf) 115 | 116 | self.view = PfView(self.scene, self) 117 | 118 | self.view.move(self.local_settings.value('windows/pf/pos', 119 | QPoint(800, 200))) 120 | self.view.resize(self.local_settings.value('windows/pf/size', 121 | QSize(300, 600))) 122 | 123 | self.event_window = EventWindow(self) 124 | 125 | self.variables_window = VariableWindow(self) 126 | # self.variables_window.show() 127 | 128 | self.mode_window = ModeWindow(self) 129 | 130 | if self.get_local_settings_bool('windows/pf/visible'): 131 | self.toggle_pf_window() 132 | 133 | if self.get_local_settings_bool('windows/events/visible'): 134 | self.toggle_event_window() 135 | 136 | if self.get_local_settings_bool('windows/devices/visible'): 137 | self.toggle_device_window() 138 | 139 | if self.get_local_settings_bool('windows/modes/visible'): 140 | self.toggle_mode_window() 141 | 142 | if self.get_local_settings_bool('windows/variables/visible'): 143 | self.toggle_variables_window() 144 | 145 | self.exit_on_close = False 146 | 147 | if self.get_local_settings_bool('settings/exit-on-close'): 148 | self.toggle_exit_on_close() 149 | 150 | self.inspector_enabled = False 151 | 152 | self.inspector_window = InspectorWindow(self) 153 | self.inspector_window.show() 154 | self.inspector_window.register_last_selected_cb() 155 | 156 | self.inspector_window.register_set_inspector_val_cb(self.set_inspector_mode) 157 | 158 | self.menu_bar = QMenuBar() 159 | self.view_menu = self.menu_bar.addMenu("&View") 160 | self.view_menu.addAction(self.toggle_pf_window_action) 161 | self.view_menu.addAction(self.toggle_device_window_action) 162 | self.view_menu.addAction(self.toggle_event_window_action) 163 | self.view_menu.addAction(self.toggle_mode_window_action) 164 | self.view_menu.addAction(self.toggle_variables_window_action) 165 | 166 | def toggle_pf_window(self): 167 | if self.view.isVisible(): 168 | self.view.hide() 169 | self.toggle_pf_window_action.setChecked(False) 170 | else: 171 | self.view.show() 172 | self.toggle_pf_window_action.setChecked(True) 173 | 174 | def toggle_device_window(self): 175 | if self.device_window.isVisible(): 176 | self.device_window.hide() 177 | self.toggle_device_window_action.setChecked(False) 178 | else: 179 | self.device_window.show() 180 | self.toggle_device_window_action.setChecked(True) 181 | 182 | def toggle_event_window(self): 183 | if self.event_window.isVisible(): 184 | self.event_window.hide() 185 | self.toggle_event_window_action.setChecked(False) 186 | else: 187 | self.event_window.show() 188 | self.toggle_event_window_action.setChecked(True) 189 | 190 | def toggle_mode_window(self): 191 | if self.mode_window.isVisible(): 192 | self.mode_window.hide() 193 | self.toggle_mode_window_action.setChecked(False) 194 | else: 195 | self.mode_window.show() 196 | self.toggle_mode_window_action.setChecked(True) 197 | 198 | def toggle_variables_window(self): 199 | if self.variables_window.isVisible(): 200 | self.variables_window.hide() 201 | self.toggle_variables_window_action.setChecked(False) 202 | else: 203 | self.variables_window.show() 204 | self.toggle_variables_window_action.setChecked(True) 205 | 206 | def toggle_exit_on_close(self): 207 | if self.exit_on_close: 208 | self.exit_on_close = False 209 | else: 210 | self.exit_on_close = True 211 | 212 | def toggle_sort_by_time(self): 213 | if self.sort_by_time: 214 | self.sort_by_time = False 215 | else: 216 | self.sort_by_time = True 217 | 218 | def except_hook(self, cls, exception, traceback): 219 | sys.__excepthook__(cls, exception, traceback) 220 | self.app.exit() 221 | 222 | def reset_connection(self): 223 | self.start_time = 0 224 | self.event_window.model.clear() 225 | self.mode_window.model.clear() 226 | 227 | def eventFilter(self, source, event): 228 | try: 229 | if source is self.playfield and event.type() == QEvent.Resize: 230 | self.playfield.setPixmap(self.playfield_image.scaled( 231 | self.playfield.size(), Qt.AspectRatioMode.KeepAspectRatio, 232 | Qt.TransformationMode.SmoothTransformation)) 233 | self.pf.invalidate_size() 234 | except AttributeError: 235 | pass 236 | 237 | return super().eventFilter(source, event) 238 | 239 | def tick(self): 240 | """ 241 | Called every 20 mSec 242 | Check the queue to see if BCP has any messages to process. 243 | If any devices have updated, refresh the model data. 244 | """ 245 | # get the complete queue 246 | with self.receive_queue.mutex: 247 | local_queue = self.receive_queue.queue 248 | self.receive_queue.queue = deque() 249 | 250 | added_events = False 251 | for cmd, kwargs in local_queue: 252 | if cmd == 'device': 253 | self.device_window.process_device_update(**kwargs) 254 | elif cmd == 'monitored_event': 255 | self.event_window.add_event_to_model(**kwargs) 256 | added_events = True 257 | elif cmd in ('mode_start', 'mode_stop', 'mode_list'): 258 | if 'running_modes' not in kwargs: 259 | # ignore mode_start/stop on newer MPF versions 260 | continue 261 | self.mode_window.process_mode_update(kwargs['running_modes']) 262 | elif cmd == 'reset': 263 | self.reset_connection() 264 | self.bcp.send("reset_complete") 265 | elif cmd == 'player_variable': 266 | self.variables_window.update_variable("player", kwargs["name"], kwargs["value"]) 267 | elif cmd == 'machine_variable': 268 | self.variables_window.update_variable("machine", kwargs["name"], kwargs["value"]) 269 | 270 | if added_events: 271 | self.event_window.update_events() 272 | 273 | def about(self): 274 | QMessageBox.about(self, "About MPF Monitor", 275 | "This is the MPF Monitor") 276 | 277 | def load_config(self): 278 | try: 279 | _yaml = yaml.YAML(typ='safe') 280 | with open(self.config_file, 'r') as f: 281 | self.config = _yaml.load(f) 282 | except FileNotFoundError: 283 | self.config = dict() 284 | 285 | def save_config(self): 286 | self.log.debug("Saving config to disk") 287 | with open(self.config_file, 'w') as f: 288 | _yaml = yaml.YAML(typ='safe') 289 | _yaml.default_flow_style = False 290 | _yaml.dump(self.config, f) 291 | 292 | def closeEvent(self, event): 293 | self.write_local_settings() 294 | event.accept() 295 | self.check_if_quit() 296 | 297 | def check_if_quit(self): 298 | if self.exit_on_close: 299 | self.log.info("Quitting due to quit on close") 300 | QCoreApplication.exit(0) 301 | 302 | def write_window_settings(self, window_name, window): 303 | settings = { 304 | 'pos': window.pos(), 305 | 'size': window.size(), 306 | 'visible': window.isVisible() 307 | } 308 | for line in settings.keys(): 309 | setting_name = 'windows/' + window_name + '/' + line 310 | self.local_settings.setValue(setting_name, settings.get(line)) 311 | 312 | def get_local_settings_bool(self, setting): 313 | return "true" == str(self.local_settings.value(setting, False)).lower() 314 | 315 | def write_local_settings(self): 316 | 317 | monitor_windows = { 318 | 'devices': self.device_window, 319 | 'pf': self.view, 320 | 'modes': self.mode_window, 321 | 'events': self.event_window, 322 | 'inspector': self.inspector_window, 323 | 'variables': self.variables_window, 324 | } 325 | 326 | for window in monitor_windows.keys(): 327 | self.write_window_settings(window, monitor_windows.get(window)) 328 | 329 | self.local_settings.setValue('settings/exit-on-close', self.exit_on_close) 330 | 331 | self.local_settings.sync() 332 | 333 | def set_inspector_mode(self, enabled=False): 334 | self.inspector_enabled = enabled 335 | self.view.set_inspector_mode_title(inspect=enabled) 336 | -------------------------------------------------------------------------------- /mpfmonitor/core/playfield.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | # For drag and drop vs click separation 4 | import time 5 | 6 | # will change these to specific imports once code is more final 7 | from PyQt6.QtCore import * 8 | from PyQt6.QtGui import * 9 | from PyQt6.QtWidgets import * 10 | 11 | from enum import Enum 12 | 13 | from mpfmonitor.core.devices import DeviceNode 14 | 15 | 16 | class Shape(Enum): 17 | DEFAULT = 0 18 | SQUARE = 1 19 | RECTANGLE = 2 20 | CIRCLE = 3 21 | TRIANGLE = 4 22 | ARROW = 5 23 | FLIPPER = 6 24 | 25 | 26 | class PfView(QGraphicsView): 27 | 28 | def __init__(self, parent, mpfmon): 29 | self.mpfmon = mpfmon 30 | super().__init__(parent) 31 | 32 | self.setWindowTitle("Playfield") 33 | self.set_inspector_mode_title(inspect=False) 34 | 35 | def resizeEvent(self, event=None): 36 | self.fitInView(self.mpfmon.pf, Qt.AspectRatioMode.KeepAspectRatio) 37 | 38 | def set_inspector_mode_title(self, inspect=False): 39 | if inspect: 40 | self.setWindowTitle('Inspector Enabled - Playfield') 41 | else: 42 | self.setWindowTitle("Playfield") 43 | 44 | def closeEvent(self, event): 45 | self.mpfmon.write_local_settings() 46 | event.accept() 47 | self.mpfmon.check_if_quit() 48 | 49 | 50 | class PfPixmapItem(QGraphicsPixmapItem): 51 | 52 | def __init__(self, image, mpfmon, parent=None): 53 | super().__init__(image, parent) 54 | 55 | self.mpfmon = mpfmon 56 | self.setAcceptDrops(True) 57 | self._height = None 58 | self._width = None 59 | 60 | def invalidate_size(self): 61 | self._height = None 62 | self._width = None 63 | 64 | @property 65 | def height(self): 66 | """Return the height of the scene.""" 67 | if self._height is None: 68 | self._height = self.mpfmon.scene.height() 69 | return self._height 70 | 71 | @property 72 | def width(self): 73 | """Return the width of the scene.""" 74 | if self._width is None: 75 | self._width = self.mpfmon.scene.width() 76 | return self._width 77 | 78 | def create_widget_from_config(self, widget, device_type, device_name): 79 | try: 80 | x = self.mpfmon.config[device_type][device_name]['x'] 81 | y = self.mpfmon.config[device_type][device_name]['y'] 82 | default_size = self.mpfmon.pf_device_size 83 | shape_str = self.mpfmon.config[device_type][device_name].get('shape', 'DEFAULT') 84 | shape = Shape[shape_str] 85 | rotation = self.mpfmon.config[device_type][device_name].get('rotation', 0) 86 | size = self.mpfmon.config[device_type][device_name].get('size', default_size) 87 | 88 | except KeyError: 89 | return 90 | 91 | x *= self.width 92 | y *= self.height 93 | 94 | self.create_pf_widget(widget, device_type, device_name, x, y, 95 | size=size, rotation=rotation, shape=shape, save=False) 96 | 97 | def dragEnterEvent(self, event): 98 | event.acceptProposedAction() 99 | 100 | dragMoveEvent = dragEnterEvent 101 | 102 | def dropEvent(self, event): 103 | device = event.source().selectedIndexes()[0] 104 | device_name = device.data() 105 | device_type = device.parent().data() 106 | 107 | drop_x = event.scenePos().x() 108 | drop_y = event.scenePos().y() 109 | 110 | try: 111 | widget = self.mpfmon.device_window.device_states[device_type][device_name] 112 | self.create_pf_widget(widget, device_type, device_name, drop_x, 113 | drop_y) 114 | except KeyError: 115 | self.mpfmon.log.warn("Invalid device dragged.") 116 | 117 | def create_pf_widget(self, widget, device_type, device_name, drop_x, 118 | drop_y, size=None, rotation=0, shape=Shape.DEFAULT, save=True): 119 | w = PfWidget(self.mpfmon, widget, device_type, device_name, drop_x, 120 | drop_y, size=size, rotation=rotation, shape_type=shape, save=save) 121 | 122 | self.mpfmon.scene.addItem(w) 123 | 124 | 125 | class PfWidget(QGraphicsItem): 126 | 127 | def __init__(self, mpfmon, widget, device_type, device_name, x, y, 128 | size=None, rotation=0, shape_type=Shape.DEFAULT, save=True): 129 | super().__init__() 130 | 131 | self.widget = widget # type: DeviceNode 132 | self.mpfmon = mpfmon 133 | self.name = device_name 134 | self.move_in_progress = True 135 | self.device_type = device_type 136 | self.set_size(size=size) 137 | self.shape_type = shape_type 138 | self.angle = rotation 139 | 140 | self.setToolTip('{}: {}'.format(self.device_type, self.name)) 141 | self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton | Qt.MouseButton.RightButton) 142 | self.setPos(x, y) 143 | self.update_pos(save) 144 | self.click_start = 0 145 | self.release_switch = False 146 | self.pen = QPen(Qt.GlobalColor.white, 3, Qt.PenStyle.SolidLine) 147 | 148 | self.log = logging.getLogger('Core') 149 | 150 | old_widget_exists = widget.set_change_callback(self.notify) 151 | 152 | if old_widget_exists: 153 | self.log.debug("Previous widget exists.") 154 | old_widget_exists(destroy=True) 155 | 156 | 157 | def boundingRect(self): 158 | known_points = self.points_for_draw_shape() 159 | if known_points != None: 160 | x_options = [sub_list[0] for sub_list in known_points] 161 | x_min = min(x_options) 162 | width = max(x_options) - x_min 163 | y_options = [sub_list[1] for sub_list in known_points] 164 | y_min = min(y_options) 165 | height = max(y_options) - y_min 166 | return QRectF(int(x_min * self.device_size), int(y_min * self.device_size), int(width * self.device_size), int(height * self.device_size)) 167 | 168 | else: 169 | return QRectF(int(self.device_size / -2), int(self.device_size / -2), 170 | int(self.device_size), int(self.device_size)) 171 | 172 | def set_shape_type(self, shape_type): 173 | if isinstance(shape_type, Shape): 174 | self.shape_type = shape_type 175 | else: 176 | self.shape_type = Shape.DEFAULT 177 | 178 | def set_rotation(self, angle=0): 179 | angle = angle % 360 180 | self.angle = angle 181 | 182 | def set_size(self, size=None): 183 | if size is None: 184 | self.size = self.mpfmon.pf_device_size 185 | self.device_size = self.mpfmon.scene.width() * \ 186 | self.mpfmon.pf_device_size 187 | else: 188 | self.size = size 189 | self.device_size = self.mpfmon.scene.width() * size 190 | 191 | def resize_to_default(self, force=False): 192 | device_config = self.mpfmon.config[self.device_type].get(self.name, None) 193 | 194 | if force: 195 | device_config.pop('size', None) # Delete saved size info, None is incase key doesn't exist (popped twice) 196 | 197 | device_size = device_config.get('size', None) 198 | 199 | if device_size is not None: 200 | # Do not change the size if it's already set 201 | pass 202 | elif device_config is not None: 203 | self.set_size() 204 | 205 | self.update_pos(save=False) # Do not save at this point. Let it be saved elsewhere. This reduces writes. 206 | 207 | def draw_shape(self): 208 | shape_result = self.shape_type 209 | 210 | # Preserve legacy and regular use 211 | if shape_result == Shape.DEFAULT: 212 | if self.device_type == 'light': 213 | shape_result = Shape.CIRCLE 214 | 215 | elif self.device_type == 'switch': 216 | shape_result = Shape.SQUARE 217 | 218 | elif self.device_type == 'diverter': 219 | shape_result = Shape.TRIANGLE 220 | 221 | else: # Draw any other devices as square by default 222 | shape_result = Shape.SQUARE 223 | 224 | return shape_result 225 | 226 | 227 | def paint(self, painter, option, widget=None): 228 | """Paint this widget to the playfield.""" 229 | painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) 230 | painter.setPen(self.pen) 231 | painter.rotate(self.angle) 232 | 233 | painter.setBrush(self.widget.get_colored_brush()) 234 | 235 | draw_shape = self.draw_shape() 236 | if draw_shape == Shape.CIRCLE: 237 | painter.drawEllipse(int(self.device_size / -2), int(self.device_size / -2), 238 | int(self.device_size), int(self.device_size)) 239 | else: 240 | shape_points = self.points_for_draw_shape() 241 | if shape_points != None: 242 | scaled_points = map(lambda pair: QPoint(int(pair[0] * self.device_size), int(pair[1] * self.device_size)), shape_points) 243 | painter.drawPolygon(QPolygon(scaled_points)) 244 | 245 | def points_for_draw_shape(self): 246 | draw_shape = self.draw_shape() 247 | if draw_shape == Shape.CIRCLE: 248 | return None # Handle circles with drawEllipse instead 249 | 250 | elif draw_shape == Shape.SQUARE: 251 | return self.square_points() 252 | 253 | elif draw_shape == Shape.RECTANGLE: 254 | return self.rectangle_points() 255 | 256 | elif draw_shape == Shape.TRIANGLE: 257 | return self.wide_triangle_points() 258 | 259 | elif draw_shape == Shape.ARROW: 260 | return self.arrow_points() 261 | 262 | elif draw_shape == Shape.FLIPPER: 263 | return self.tall_triangle_points() 264 | 265 | def square_points(self): 266 | return [[-.5, -.5], [.5, -.5], [.5, .5], [-.5, .5]] 267 | 268 | def rectangle_points(self): 269 | return [[-.2, -.5], [.2, -.5], [.2, .5], [-.2, .5]] 270 | 271 | def wide_triangle_points(self): 272 | return [[0, -.6], [-.6, .3], [.6, .3]] 273 | 274 | def arrow_points(self): 275 | return [[0, -.7], [-.4, 0], [-.2, 0], [-.2, .4], [.2, .4], [.2, 0], [.4, 0]] 276 | 277 | def tall_triangle_points(self): 278 | return [[0, -.3], [-.3, .7], [.3, .7]] 279 | 280 | def notify(self, destroy=False, resize=False): 281 | self.update() 282 | 283 | if destroy: 284 | self.destroy() 285 | 286 | def destroy(self): 287 | self.log.debug("Destroy device: %s", self.name) 288 | self.mpfmon.scene.removeItem(self) 289 | self.delete_from_config() 290 | 291 | def mouseMoveEvent(self, event): 292 | if (self.mpfmon.pf.boundingRect().width() > event.scenePos().x() > 293 | 0) and (self.mpfmon.pf.boundingRect().height() > 294 | event.scenePos().y() > 0): 295 | # devices off the pf do weird things at the moment 296 | 297 | if time.time() - self.click_start > .3: 298 | self.setPos(event.scenePos()) 299 | self.move_in_progress = True 300 | 301 | def mousePressEvent(self, event): 302 | self.click_start = time.time() 303 | 304 | if self.device_type == 'switch': 305 | if event.buttons() & Qt.MouseButton.RightButton: 306 | if not self.get_val_inspector_enabled(): 307 | self.mpfmon.bcp.send('switch', name=self.name, state=-1) 308 | self.release_switch = False 309 | else: 310 | self.send_to_inspector_window() 311 | self.log.debug('Switch %s right clicked', self.name) 312 | elif event.buttons() & Qt.MouseButton.LeftButton: 313 | if not self.get_val_inspector_enabled(): 314 | self.mpfmon.bcp.send('switch', name=self.name, state=-1) 315 | self.release_switch = True 316 | else: 317 | self.send_to_inspector_window() 318 | self.log.debug('Switch %s clicked', self.name) 319 | 320 | else: 321 | if event.buttons() & Qt.MouseButton.RightButton: 322 | if self.get_val_inspector_enabled(): 323 | self.send_to_inspector_window() 324 | self.log.debug('%s %s right clicked', self.device_type, self.name) 325 | elif event.buttons() & Qt.MouseButton.LeftButton: 326 | if self.get_val_inspector_enabled(): 327 | self.send_to_inspector_window() 328 | self.log.debug('%s %s clicked', self.device_type, self.name) 329 | 330 | def mouseReleaseEvent(self, event): 331 | if self.move_in_progress and time.time() - self.click_start > .5: 332 | self.move_in_progress = False 333 | self.update_pos() 334 | 335 | elif self.release_switch: 336 | self.mpfmon.bcp.send('switch', name=self.name, state=-1) 337 | 338 | self.click_start = 0 339 | 340 | def update_pos(self, save=True): 341 | x = self.pos().x() / self.mpfmon.scene.width() if self.mpfmon.scene.width() > 0 else self.pos().x() 342 | y = self.pos().y() / self.mpfmon.scene.height() if self.mpfmon.scene.height() > 0 else self.pos().y() 343 | 344 | if self.device_type not in self.mpfmon.config: 345 | self.mpfmon.config[self.device_type] = dict() 346 | 347 | if self.name not in self.mpfmon.config[self.device_type]: 348 | self.mpfmon.config[self.device_type][self.name] = dict() 349 | 350 | self.mpfmon.config[self.device_type][self.name]['x'] = x 351 | self.mpfmon.config[self.device_type][self.name]['y'] = y 352 | 353 | # Only save the shape if it is different than the default 354 | conf_shape_str = self.mpfmon.config[self.device_type][self.name].get('shape', 'DEFAULT') 355 | conf_shape = Shape[str(conf_shape_str).upper()] 356 | 357 | if self.shape_type is not conf_shape: 358 | if self.shape_type is not Shape.DEFAULT: 359 | self.mpfmon.config[self.device_type][self.name]['shape'] = self.shape_type.name 360 | else: 361 | try: 362 | self.mpfmon.config[self.device_type][self.name].pop('shape') 363 | except: 364 | pass 365 | 366 | # Only save the rotation if it has been changed 367 | conf_angle = self.mpfmon.config[self.device_type][self.name].get('angle', -1) 368 | 369 | if self.angle is not conf_angle: 370 | if self.angle != 0: 371 | self.mpfmon.config[self.device_type][self.name]['rotation'] = self.angle 372 | else: 373 | try: 374 | self.mpfmon.config[self.device_type][self.name].pop('rotation') 375 | except: 376 | pass 377 | 378 | # Only save the size if it is different than the top level default 379 | default_size = self.mpfmon.pf_device_size 380 | conf_size = self.mpfmon.config[self.device_type][self.name].get('size', default_size) 381 | 382 | if self.size is not conf_size \ 383 | and self.size is not self.mpfmon.pf_device_size: 384 | self.mpfmon.config[self.device_type][self.name]['size'] = self.size 385 | 386 | if save: 387 | self.mpfmon.save_config() 388 | 389 | def delete_from_config(self): 390 | self.mpfmon.config[self.device_type].pop(self.name) 391 | self.mpfmon.save_config() 392 | 393 | def get_val_inspector_enabled(self): 394 | return self.mpfmon.inspector_enabled 395 | 396 | def send_to_inspector_window(self): 397 | self.mpfmon.inspector_window_last_selected_cb(pf_widget=self) 398 | -------------------------------------------------------------------------------- /mpfmonitor/core/devices.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | import os 4 | 5 | # will change these to specific imports once code is more final 6 | from PyQt6.QtCore import * 7 | from PyQt6.QtGui import * 8 | from PyQt6.QtWidgets import * 9 | from PyQt6 import uic 10 | 11 | BRUSH_WHITE = QBrush(QColor(255, 255, 255), Qt.BrushStyle.SolidPattern) 12 | BRUSH_GREEN = QBrush(QColor(0, 255, 0), Qt.BrushStyle.SolidPattern) 13 | BRUSH_BLACK = QBrush(QColor(0, 0, 0), Qt.BrushStyle.SolidPattern) 14 | BRUSH_DARK_PURPLE = QBrush(QColor(128, 0, 255), Qt.BrushStyle.SolidPattern) 15 | 16 | 17 | class DeviceNode: 18 | 19 | __slots__ = ["_callback", "_name", "_data", "_type", "_brush", "q_name", "q_state", "sub_properties", 20 | "sub_properties_appended", "q_time_added", "log"] 21 | 22 | def __init__(self): 23 | self._callback = None 24 | self._name = "" 25 | self._data = {} 26 | self._type = "" 27 | self._brush = BRUSH_BLACK 28 | 29 | self.q_name = QStandardItem() 30 | self.q_state = QStandardItem() 31 | self.sub_properties = {} 32 | self.sub_properties_appended = False 33 | 34 | self.q_time_added = QStandardItem() 35 | self.q_time_added.setData(time.perf_counter(), Qt.ItemDataRole.DisplayRole) 36 | 37 | self.q_name.setDragEnabled(True) 38 | self.q_state.setData("", Qt.ItemDataRole.DisplayRole) 39 | 40 | self.log = logging.getLogger('Device') 41 | 42 | def setName(self, name): 43 | self._name = name 44 | self.q_name.setData(str(self._name), Qt.ItemDataRole.DisplayRole) 45 | self.log = logging.getLogger('Device {}'.format(self._name)) 46 | self.q_state.emitDataChanged() 47 | 48 | def setData(self, data): 49 | """Set data of device.""" 50 | if data == self._data: 51 | # do nothing if data did not change 52 | return 53 | 54 | if not isinstance(data, dict): 55 | data = {} 56 | 57 | if self._callback: 58 | self._callback() 59 | 60 | self._data = data 61 | 62 | state_str = str(list(self._data.values())[0]) 63 | if len(self._data) > 1: 64 | state_str = state_str + " {…}" 65 | self.q_state.setData(state_str, Qt.ItemDataRole.DisplayRole) 66 | 67 | for row in self._data: 68 | if not self.sub_properties_appended: 69 | q_property = QStandardItem() 70 | q_value = QStandardItem() 71 | self.sub_properties.update({row: [q_property, q_value]}) 72 | self.q_name.appendRow(self.sub_properties.get(row)) 73 | 74 | self.sub_properties.get(row)[0].setData(str(row), Qt.ItemDataRole.DisplayRole) 75 | self.sub_properties.get(row)[1].setData(str(self._data.get(row)), Qt.ItemDataRole.DisplayRole) 76 | 77 | self.sub_properties_appended = True 78 | self.q_state.emitDataChanged() 79 | self._brush = self._calculate_colored_brush() 80 | 81 | def setType(self, type): 82 | self._type = type 83 | self._brush = self._calculate_colored_brush() 84 | self.q_state.emitDataChanged() 85 | 86 | def get_row(self): 87 | return [self.q_name, self.q_state, self.q_time_added] 88 | 89 | def data(self): 90 | return self._data 91 | 92 | def type(self): 93 | return self._type 94 | 95 | def get_colored_brush(self) -> QBrush: 96 | """Return colored brush for device.""" 97 | return self._brush 98 | 99 | def _calculate_color_gamma_correction(self, color): 100 | """Perform gamma correction. 101 | 102 | Feel free to fiddle with these constants until it feels right 103 | With gamma = 0.5 and constant a = 18, the top 54 values are lost, 104 | but the bottom 25% feels much more normal. 105 | """ 106 | gamma = 0.5 107 | a = 18 108 | corrected = [] 109 | 110 | for value in color: 111 | if value < 0 or value > 255: 112 | self.log.warning("Got value %s for brightness which outside the expected range", value) 113 | value = 0 114 | 115 | value = int(pow(value, gamma) * a) 116 | if value > 255: 117 | value = 255 118 | corrected.append(value) 119 | 120 | return corrected 121 | 122 | def _calculate_colored_brush(self): 123 | if self._type == 'light': 124 | color = self.data()['color'] 125 | if color == [0, 0, 0]: 126 | # shortcut for black 127 | return BRUSH_BLACK 128 | color = self._calculate_color_gamma_correction(color) 129 | 130 | elif self._type == 'switch': 131 | state = self.data()['state'] 132 | 133 | if state: 134 | return BRUSH_GREEN 135 | else: 136 | return BRUSH_BLACK 137 | 138 | elif self._type == 'diverter': 139 | state = self.data()['active'] 140 | 141 | if state: 142 | return BRUSH_DARK_PURPLE 143 | else: 144 | return BRUSH_BLACK 145 | else: 146 | # Get first parameter and draw as white if it evaluates True 147 | state = bool(list(self.data().values())[0]) 148 | if state: 149 | return BRUSH_WHITE 150 | else: 151 | return BRUSH_BLACK 152 | 153 | return QBrush(QColor(*color), Qt.BrushStyle.SolidPattern) 154 | 155 | def set_change_callback(self, callback): 156 | if self._callback: 157 | # raise AssertionError("Can only have one callback") 158 | old_callback = self._callback 159 | self._callback = callback 160 | return old_callback 161 | else: 162 | self._callback = callback 163 | self.q_state.emitDataChanged() 164 | 165 | 166 | class DeviceDelegate(QStyledItemDelegate): 167 | def __init__(self): 168 | self.size = None 169 | super().__init__() 170 | 171 | def paint(self, painter, view, index): 172 | super().paint(painter, view, index) 173 | color = None 174 | state = None 175 | balls = None 176 | found = False 177 | text = '' 178 | 179 | # src_index = index.model().mapToSource(index) 180 | # src_index_model = src_index.model() 181 | # print(index.data()) 182 | # print(src_index_model.data()) 183 | data = [] 184 | try: 185 | data = index.model().itemFromIndex(index).data() 186 | # src_index = index.model().mapToSource(index) 187 | # data = index.model().data(src_index) 188 | except: 189 | pass 190 | 191 | 192 | num_circles = 1 193 | # return 194 | 195 | if index.column() == 0: 196 | return 197 | 198 | try: 199 | if 'color' in data: 200 | color = data['color'] 201 | found = True 202 | except TypeError: 203 | return 204 | 205 | try: 206 | if 'brightness' in data: 207 | color = [data['brightness']]*3 208 | found = True 209 | except TypeError: 210 | return 211 | 212 | try: 213 | if 'state' in data: 214 | text = str(data['state']) 215 | found = True 216 | except TypeError: 217 | return 218 | 219 | try: 220 | if 'complete' in data: 221 | state = not data['complete'] 222 | found = True 223 | except TypeError: 224 | return 225 | 226 | try: 227 | if 'enabled' in data: 228 | state = data['enabled'] 229 | found = True 230 | except TypeError: 231 | return 232 | 233 | try: 234 | if 'balls' in data: 235 | balls = data['balls'] 236 | found = True 237 | except TypeError: 238 | return 239 | 240 | try: 241 | if 'balls_locked' in data: 242 | balls = data['balls_locked'] 243 | found = True 244 | except TypeError: 245 | return 246 | 247 | try: 248 | if 'num_balls_requested' in data: 249 | text += 'Requested: {} '.format( 250 | data['num_balls_requested']) 251 | found = True 252 | except TypeError: 253 | return 254 | 255 | try: 256 | if 'unexpected_balls' in data: 257 | text += 'Unexpected: {} '.format( 258 | data['unexpected_balls']) 259 | found = True 260 | except TypeError: 261 | return 262 | 263 | if not found: 264 | return 265 | 266 | text += " " + str(data) 267 | 268 | painter.save() 269 | 270 | painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) 271 | painter.setPen(QPen(QColor(100, 100, 100), 1, Qt.PenStyle.SolidLine)) 272 | 273 | if color: 274 | painter.setBrush(QBrush(QColor(*color), Qt.BrushStyle.SolidPattern)) 275 | elif state is True: 276 | painter.setBrush(QBrush(QColor(0, 255, 0), Qt.BrushStyle.SolidPattern)) 277 | elif state is False: 278 | painter.setBrush(QBrush(QColor(255, 255, 255), Qt.BrushStyle.SolidPattern)) 279 | elif isinstance(balls, int): 280 | painter.setBrush(QBrush(QColor(0, 255, 0), Qt.BrushStyle.SolidPattern)) 281 | num_circles = balls 282 | 283 | x_offset = 0 284 | for _ in range(num_circles): 285 | painter.drawEllipse( 286 | view.rect.x() + x_offset, view.rect.y(), 14, 14) 287 | 288 | x_offset += 20 289 | 290 | if text: 291 | painter.drawText(view.rect.x() + x_offset, view.rect.y() + 12, 292 | str(text)) 293 | self.size = QSize(len(text) * 10, 20) 294 | 295 | painter.restore() 296 | 297 | def sizeHint(self, QStyleOptionViewItem, QModelIndex): 298 | if self.size: 299 | return self.size 300 | else: 301 | # Calling super() here seems to result in a segfault on close sometimes. 302 | # return super().sizeHint(QStyleOptionViewItem, QModelIndex) 303 | return QSize(80, 20) 304 | 305 | 306 | class DeviceWindow(QWidget): 307 | 308 | __slots__ = ["mpfmn", "ui", "model", "log", "already_hidden", "added_index", "device_states", 309 | "device_type_widgets", "_debug_enabled"] 310 | 311 | def __init__(self, mpfmon): 312 | self.mpfmon = mpfmon 313 | super().__init__() 314 | self.ui = None 315 | self.model = None 316 | self.draw_ui() 317 | self.attach_model() 318 | self.attach_signals() 319 | 320 | self.log = logging.getLogger('Core') 321 | 322 | self.already_hidden = False 323 | self.added_index = 0 324 | 325 | self.device_states = dict() 326 | self.device_type_widgets = dict() 327 | self._debug_enabled = self.log.isEnabledFor(logging.DEBUG) 328 | 329 | def draw_ui(self): 330 | # Load ui file from ./ui/ 331 | ui_path = os.path.join(os.path.dirname(__file__), "ui", "searchable_tree.ui") 332 | self.ui = uic.loadUi(ui_path, self) 333 | 334 | self.ui.setWindowTitle('Devices') 335 | 336 | self.ui.move(self.mpfmon.local_settings.value('windows/devices/pos', 337 | QPoint(200, 200))) 338 | self.ui.resize(self.mpfmon.local_settings.value('windows/devices/size', 339 | QSize(300, 600))) 340 | 341 | # Disable option "Sort", select first item. 342 | # TODO: Store and load selected sort index to local_settings 343 | self.ui.sortComboBox.model().item(0).setEnabled(False) 344 | self.ui.sortComboBox.setCurrentIndex(1) 345 | self.ui.treeView.setAlternatingRowColors(True) 346 | 347 | def attach_signals(self): 348 | assert (self.ui is not None) 349 | self.ui.treeView.expanded.connect(self.resize_columns_to_content) 350 | self.ui.treeView.collapsed.connect(self.resize_columns_to_content) 351 | self.ui.filterLineEdit.textChanged.connect(self.filter_text) 352 | self.ui.sortComboBox.currentIndexChanged.connect(self.change_sort) 353 | 354 | def attach_model(self): 355 | assert (self.ui is not None) 356 | self.treeview = self.ui.treeView 357 | 358 | self.model = QStandardItemModel() 359 | self.model.setHorizontalHeaderLabels(["Device", "Data"]) 360 | 361 | self.treeview.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly) 362 | # self.treeview.setItemDelegateForColumn(1, DeviceDelegate()) 363 | 364 | # Resizing to contents causes huge performance losses. Only resize when rows expanded or collapsed. 365 | # self.treeview.header().setSectionResizeMode(QHeaderView.ResizeToContents) 366 | 367 | self.filtered_model = QSortFilterProxyModel(self) 368 | self.filtered_model.setSourceModel(self.model) 369 | self.filtered_model.setRecursiveFilteringEnabled(True) 370 | self.filtered_model.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) 371 | 372 | self.treeview.setModel(self.filtered_model) 373 | 374 | def resize_columns_to_content(self): 375 | self.ui.treeView.resizeColumnToContents(0) 376 | self.ui.treeView.resizeColumnToContents(1) 377 | 378 | def process_device_update(self, name, state, changes, type): 379 | del changes 380 | if self._debug_enabled: 381 | self.log.debug("Device Update: %s.%s: %s", type, name, state) 382 | 383 | if type not in self.device_states: 384 | self.device_states[type] = dict() 385 | 386 | item = QStandardItem(type) 387 | self.device_type_widgets[type] = item 388 | 389 | self.model.appendRow([item, QStandardItem(), QStandardItem(str(time.perf_counter()))]) 390 | 391 | if name not in self.device_states[type]: 392 | node = DeviceNode() 393 | node.setName(name) 394 | node.setData(state) 395 | node.setType(type) 396 | 397 | self.device_states[type][name] = node 398 | self.device_type_widgets[type].appendRow(node.get_row()) 399 | 400 | self.mpfmon.pf.create_widget_from_config(node, type, name) 401 | else: 402 | self.device_states[type][name].setData(state) 403 | 404 | self.ui.treeView.setColumnHidden(2, True) 405 | 406 | def filter_text(self, string): 407 | wc_string = "*" + str(string) + "*" 408 | self.filtered_model.setFilterWildcard(wc_string) 409 | self.ui.treeView.resizeColumnToContents(0) 410 | self.ui.treeView.resizeColumnToContents(1) 411 | 412 | def change_sort(self, index=1): 413 | self.model.layoutAboutToBeChanged.emit() 414 | self.filtered_model.beginResetModel() 415 | 416 | # This is a bit sloppy and probably should be reworked. 417 | if index == 1: # Received up 418 | self.filtered_model.sort(2, Qt.SortOrder.AscendingOrder) 419 | elif index == 2: # Received down 420 | self.filtered_model.sort(2, Qt.SortOrder.DescendingOrder) 421 | elif index == 3: # Name up 422 | self.filtered_model.sort(0, Qt.SortOrder.AscendingOrder) 423 | elif index == 4: # Name down 424 | self.filtered_model.sort(0, Qt.SortOrder.DescendingOrder) 425 | 426 | self.filtered_model.endResetModel() 427 | self.model.layoutChanged.emit() 428 | 429 | def closeEvent(self, event): 430 | super().closeEvent(event) 431 | self.mpfmon.write_local_settings() 432 | event.accept() 433 | self.mpfmon.check_if_quit() 434 | --------------------------------------------------------------------------------