├── 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 |
--------------------------------------------------------------------------------