diff --git a/NEWS.adoc b/NEWS.adoc index 8da073ce8e..00f1b143c2 100644 --- a/NEWS.adoc +++ b/NEWS.adoc @@ -66,6 +66,10 @@ https://github.com/networkupstools/nut/milestone/13 - Second-level bullet points listed in this file will now use 4 spaces (not 3 like before) for easier initial indentation of new entries. + - The PyQt5 and PyQt6 NUT-Monitor commands now share one Python 3 + implementation. The dispatcher accepts `NUT_MONITOR_QT=5` or + `NUT_MONITOR_QT=6` to require a particular backend. [PR #3596] + - Introduced an experimental `apcmicrolink` driver for devices with the APC Microlink protocol on serial port connections. Tested against APC Smart-UPS 750 (SMT750RMI2UC). [PR #3406] diff --git a/configure.ac b/configure.ac index e9e889162b..c298879b9a 100644 --- a/configure.ac +++ b/configure.ac @@ -7478,6 +7478,11 @@ AC_CONFIG_FILES([ Makefile ]) +AC_CONFIG_FILES([scripts/python/app/NUT-Monitor-py3qt5:scripts/python/app/NUT-Monitor-py3qt.in], + [chmod +x scripts/python/app/NUT-Monitor-py3qt5]) +AC_CONFIG_FILES([scripts/python/app/NUT-Monitor-py3qt6:scripts/python/app/NUT-Monitor-py3qt.in], + [chmod +x scripts/python/app/NUT-Monitor-py3qt6]) + AC_MSG_NOTICE([Generating templated script files that should be marked executable]) m4_foreach_w([SCRIPTFILE], [ docs/docinfo.xml.sh @@ -7489,8 +7494,6 @@ m4_foreach_w([SCRIPTFILE], [ scripts/augeas/gen-nutupsconf-aug.py scripts/external_apis/enphase/enphase-monitor scripts/python/app/NUT-Monitor-py2gtk2 - scripts/python/app/NUT-Monitor-py3qt5 - scripts/python/app/NUT-Monitor-py3qt6 scripts/python/module/test_nutclient.py scripts/upsdrvsvcctl/nut-driver-enumerator.sh scripts/upsdrvsvcctl/upsdrvsvcctl diff --git a/docs/configure.txt b/docs/configure.txt index ccd9064b8a..e42880e156 100644 --- a/docs/configure.txt +++ b/docs/configure.txt @@ -535,9 +535,10 @@ of Python does not matter and the un-versioned `PYTHON` value is substituted into files as the script shebang, used to find the `site-packages` location, etc. -One exception is generation of NUT-Monitor GUI application, being separated -for `NUT-Monitor-py2gtk2`, `NUT-Monitor-py3qt5`, `NUT-Monitor-py3qt6` due -to further backend platform technical differences -- these build products +One exception is generation of NUT-Monitor GUI application. It provides a +Python 2/GTK2 implementation and one shared Python 3 implementation for the +Qt5 and Qt6 backends, exposed as `NUT-Monitor-py2gtk2`, +`NUT-Monitor-py3qt5` and `NUT-Monitor-py3qt6`. These build products specifically use `PYTHON2` and `PYTHON3` substitutions. They may also be co-installed on the same system. A dispatcher shell script `NUT-Monitor` is used to launch the preferred (newest) or the only existing implementation. diff --git a/docs/man/NUT-Monitor.txt b/docs/man/NUT-Monitor.txt index 1df546a06b..3ff16fbede 100644 --- a/docs/man/NUT-Monitor.txt +++ b/docs/man/NUT-Monitor.txt @@ -15,7 +15,7 @@ SYNOPSIS *NUT-Monitor-py3qt6* -[PREFER_PY2=true] [PYTHON2=python2.7] [PYTHON3=python3.14w] *NUT-Monitor* +[PREFER_PY2=true] [NUT_MONITOR_QT=auto|5|6] [PYTHON2=python2.7] [PYTHON3=python3.14w] *NUT-Monitor* DESCRIPTION ----------- @@ -30,11 +30,11 @@ as part of NUT; also releases of the module are published as a PyPI package. * `NUT-Monitor-py2gtk2` is an implementation that relies on a Python 2.x interpreter (tested with 2.6 or 2.7 generation) and GTK2 UI library; -* `NUT-Monitor-py3qt5` is an implementation that relies on a Python 3.x - interpreter and QT5 UI library; +* `NUT-Monitor-py3qt5` runs the shared Python 3.x implementation with the + QT5 UI library; -* `NUT-Monitor-py3qt6` is an implementation that relies on a Python 3.x - interpreter and QT6 UI library; +* `NUT-Monitor-py3qt6` runs the shared Python 3.x implementation with the + QT6 UI library; * `NUT-Monitor` is a shell script to pick at run-time the best implementation. In older NUT releases, this was the file name used by the Python 2.x variant @@ -82,6 +82,12 @@ The `PREFER_PY2=true` or `PREFER_PY2=false` environment variable value can be used to select the implementation of the client to use if both ecosystems are usable on the current system. +The `NUT_MONITOR_QT=5` or `NUT_MONITOR_QT=6` environment variable requires +that Python 3 backend when using the `NUT-Monitor` dispatcher. The default +value `auto` prefers Qt6 and falls back to Qt5. The explicit +`NUT-Monitor-py3qt5` and `NUT-Monitor-py3qt6` commands always select their +named backend. + Note that the implementation for Python 2.x / GTK2 is slightly better regarding localization; functionally they should be equivalent. diff --git a/scripts/python/Makefile.am b/scripts/python/Makefile.am index 40a0d8e04d..55cacb12dd 100644 --- a/scripts/python/Makefile.am +++ b/scripts/python/Makefile.am @@ -46,8 +46,8 @@ NUT_MONITOR_PY3_COMMON = \ NUT_MONITOR_PY3QT5 = \ app/nut-monitor-py3qt5.desktop -NUT_MONITOR_PY3QT5_TEMPLATE = \ - app/NUT-Monitor-py3qt5.in +NUT_MONITOR_PY3QT_TEMPLATE = \ + app/NUT-Monitor-py3qt.in NUT_MONITOR_PY3QT5_GENERATED_SCRIPT = \ app/NUT-Monitor-py3qt5 @@ -55,9 +55,6 @@ NUT_MONITOR_PY3QT5_GENERATED_SCRIPT = \ NUT_MONITOR_PY3QT6 = \ app/nut-monitor-py3qt6.desktop -NUT_MONITOR_PY3QT6_TEMPLATE = \ - app/NUT-Monitor-py3qt6.in - NUT_MONITOR_PY3QT6_GENERATED_SCRIPT = \ app/NUT-Monitor-py3qt6 @@ -153,17 +150,18 @@ NUT_MONITOR_DISPATCHER_NOEXEC = \ NUT_MONITOR_DISPATCHER_SCRIPT = \ app/NUT-Monitor +TESTS = app/NUT-Monitor-test.sh + ################################################################# # `make dist` tarball contents: EXTRA_DIST = \ $(NUT_MONITOR_DISPATCHER_NOEXEC) $(NUT_MONITOR_DISPATCHER_SCRIPT) \ $(NUT_MONITOR_COMMON) $(NUT_MONITOR_COMMON_TEMPLATE) \ - $(PYNUT_COMMON) $(PYNUT_TEMPLATE) + $(PYNUT_COMMON) $(PYNUT_TEMPLATE) $(TESTS) EXTRA_DIST += $(NUT_MONITOR_PY2GTK2) $(NUT_MONITOR_PY2GTK2_TEMPLATE) -EXTRA_DIST += $(NUT_MONITOR_PY3_COMMON) \ - $(NUT_MONITOR_PY3QT5) $(NUT_MONITOR_PY3QT5_TEMPLATE) \ - $(NUT_MONITOR_PY3QT6) $(NUT_MONITOR_PY3QT6_TEMPLATE) +EXTRA_DIST += $(NUT_MONITOR_PY3_COMMON) $(NUT_MONITOR_PY3QT_TEMPLATE) \ + $(NUT_MONITOR_PY3QT5) $(NUT_MONITOR_PY3QT6) ################################################################# # `make install` handling (nobase_ to keep tree structure): diff --git a/scripts/python/README.adoc b/scripts/python/README.adoc index 2df7ef9fc4..4d1f9f656c 100644 --- a/scripts/python/README.adoc +++ b/scripts/python/README.adoc @@ -44,9 +44,9 @@ This directory contains the `NUT-Monitor` UI application, which uses the NOTE: Do not mistake it for `nut-monitor` service for the `upsmon` client in some distributions' NUT packaging. -There are three closely related separate implementations, for Python 2 with GTK2 -and for Python 3 with Qt5 and Qt6. Either can be installed at the same time, if -your distribution has not yet outlawed the obsolete Python 2 interpreters. +There is a Python 2/GTK2 implementation and a shared Python 3 implementation +for the Qt5 and Qt6 backends. They can be installed at the same time, if your +distribution has not yet outlawed the obsolete Python 2 interpreters. To install it, you will either need to keep the files together, or to install: diff --git a/scripts/python/app/NUT-Monitor b/scripts/python/app/NUT-Monitor index 380f9694cf..46764bc887 100755 --- a/scripts/python/app/NUT-Monitor +++ b/scripts/python/app/NUT-Monitor @@ -19,6 +19,14 @@ case "${PREFER_PY2-}" in ;; esac +case "${NUT_MONITOR_QT-auto}" in + ""|auto) NUT_MONITOR_QT=auto ;; + 5|6) ;; + *) echo "Unsupported value of NUT_MONITOR_QT='$NUT_MONITOR_QT'; expected 'auto', '5' or '6'" >&2 + exit 2 + ;; +esac + # Detect which variant of NUT-Monitor we can run on the local system: [ -s "$0"-py2gtk2 -a -x "$0"-py2gtk2 ] && PYTHON_PY2GTK2_SHEBANG="`head -1 \"$0\"-py2gtk2 | sed 's,^#!,,'`" || PYTHON_PY2GTK2_SHEBANG="" [ -s "$0"-py3qt5 -a -x "$0"-py3qt5 ] && PYTHON_PY3QT5_SHEBANG="`head -1 \"$0\"-py3qt5 | sed 's,^#!,,'`" || PYTHON_PY3QT5_SHEBANG="" @@ -75,28 +83,31 @@ for P in "$PYTHON_PY2GTK2" "$PYTHON_PY3QT5" "$PYTHON_PY3QT6" ; do fi done -if [ -n "$PYTHON_PY2GTK2" ] && [ -n "$PYTHON_PY3QT5" ] && [ -n "$PYTHON_PY3QT6" ] ; then - if $PREFER_PY2 ; then - echo "Starting $0-py2gtk2 variant..." >&2 - exec $PYTHON_PY2GTK2 "$0"-py2gtk2 "$@" - else - echo "Starting $0-py3qt6 variant..." >&2 - exec $PYTHON_PY3QT6 "$0"-py3qt6 "$@" - fi -else - if [ -n "$PYTHON_PY2GTK2" ] ; then - echo "Starting $0-py2gtk2 variant..." >&2 - exec $PYTHON_PY2GTK2 "$0"-py2gtk2 "$@" - fi - if [ -n "$PYTHON_PY3QT6" ] ; then - echo "Starting $0-py3qt6 variant..." >&2 - exec $PYTHON_PY3QT6 "$0"-py3qt6 "$@" - fi - if [ -n "$PYTHON_PY3QT5" ] ; then - echo "Starting $0-py3qt5 variant..." >&2 - exec $PYTHON_PY3QT5 "$0"-py3qt5 "$@" - fi +case "$NUT_MONITOR_QT" in + 5) PYTHON_PY2GTK2=""; PYTHON_PY3QT6="" ;; + 6) PYTHON_PY2GTK2=""; PYTHON_PY3QT5="" ;; +esac + +if [ -n "$PYTHON_PY2GTK2" ] && $PREFER_PY2 ; then + echo "Starting $0-py2gtk2 variant..." >&2 + exec $PYTHON_PY2GTK2 "$0"-py2gtk2 "$@" +fi +if [ -n "$PYTHON_PY3QT6" ] ; then + echo "Starting $0-py3qt6 variant..." >&2 + exec $PYTHON_PY3QT6 "$0"-py3qt6 "$@" +fi +if [ -n "$PYTHON_PY3QT5" ] ; then + echo "Starting $0-py3qt5 variant..." >&2 + exec $PYTHON_PY3QT5 "$0"-py3qt5 "$@" +fi +if [ -n "$PYTHON_PY2GTK2" ] ; then + echo "Starting $0-py2gtk2 variant..." >&2 + exec $PYTHON_PY2GTK2 "$0"-py2gtk2 "$@" fi -echo "ERROR: No usable Python interpreter version (with needed modules) was found" >&2 +if [ "$NUT_MONITOR_QT" = auto ] ; then + echo "ERROR: No usable Python interpreter version (with needed modules) was found" >&2 +else + echo "ERROR: Requested PyQt$NUT_MONITOR_QT implementation is not usable" >&2 +fi exit 1 diff --git a/scripts/python/app/NUT-Monitor-py3qt6.in b/scripts/python/app/NUT-Monitor-py3qt.in similarity index 94% rename from scripts/python/app/NUT-Monitor-py3qt6.in rename to scripts/python/app/NUT-Monitor-py3qt.in index 9698df4986..baa247819d 100755 --- a/scripts/python/app/NUT-Monitor-py3qt6.in +++ b/scripts/python/app/NUT-Monitor-py3qt.in @@ -45,11 +45,12 @@ # Added support for reconnections (client sleep, server restart) # Fixed TAB/SHIFT-TAB focus change order through login page # - -import PyQt6.uic -from PyQt6.QtCore import * -from PyQt6.QtGui import * -from PyQt6.QtWidgets import * +# 2026-09-01 user01010111 - Version 2.2.0 +# Share one Python implementation across PyQt5 and PyQt6. +# +# The configure.ac recipe uses this template to generate the legacy +# NUT-Monitor-py3qt5 and NUT-Monitor-py3qt6 command names. The invoked +# filename selects the Qt backend. import sys import socket @@ -58,12 +59,69 @@ import os, os.path import stat import platform import time -import threading import optparse import configparser import locale import gettext +program_name = os.path.basename(sys.argv[0]) +if program_name.endswith("-py3qt5"): + qt_version = 5 +elif program_name.endswith("-py3qt6"): + qt_version = 6 +else: + qt_request = os.getenv("NUT_MONITOR_QT", "auto") + if qt_request == "5": + qt_version = 5 + elif qt_request == "6": + qt_version = 6 + elif qt_request in ("", "auto"): + try: + import PyQt6.uic + qt_version = 6 + except ImportError: + qt_version = 5 + else: + sys.stderr.write("Unsupported value of NUT_MONITOR_QT='%s'; expected 'auto', '5' or '6'\n" % qt_request) + sys.exit(2) + +if qt_version == 6: + import PyQt6.uic as qt_uic + from PyQt6.QtCore import * + from PyQt6.QtGui import * + from PyQt6.QtWidgets import * + + QT_HORIZONTAL = Qt.Orientation.Horizontal + QT_ASCENDING_ORDER = Qt.SortOrder.AscendingOrder + QT_ITEM_FLAGS = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemNeverHasChildren + DIALOG_ACCEPTED = QDialog.DialogCode.Accepted + DIALOG_BUTTON_OK = QDialogButtonBox.StandardButton.Ok + MESSAGE_BUTTON_YES = QMessageBox.StandardButton.Yes + MESSAGE_BUTTON_NO = QMessageBox.StandardButton.No + LINE_EDIT_NORMAL = QLineEdit.EchoMode.Normal + STYLE_MESSAGE_INFO = QStyle.StandardPixmap.SP_MessageBoxInformation + DIALOG_HELP_ROLE = QDialogButtonBox.ButtonRole.HelpRole + TRAY_TRIGGER = QSystemTrayIcon.ActivationReason.Trigger + TRAY_INFO = QSystemTrayIcon.MessageIcon.Information +else: + import PyQt5.uic as qt_uic + from PyQt5.QtCore import * + from PyQt5.QtGui import * + from PyQt5.QtWidgets import * + + QT_HORIZONTAL = Qt.Horizontal + QT_ASCENDING_ORDER = Qt.AscendingOrder + QT_ITEM_FLAGS = Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemNeverHasChildren + DIALOG_ACCEPTED = QDialog.Accepted + DIALOG_BUTTON_OK = QDialogButtonBox.Ok + MESSAGE_BUTTON_YES = QMessageBox.Yes + MESSAGE_BUTTON_NO = QMessageBox.No + LINE_EDIT_NORMAL = QLineEdit.Normal + STYLE_MESSAGE_INFO = QStyle.SP_MessageBoxInformation + DIALOG_HELP_ROLE = QDialogButtonBox.HelpRole + TRAY_TRIGGER = QSystemTrayIcon.Trigger + TRAY_INFO = QSystemTrayIcon.Information + # Try local development/accompanying packaging first, # then system installation sys_path_orig = sys.path.copy() @@ -160,13 +218,13 @@ class interface : self.__app = QApplication( argv ) try: - self.__app.setDesktopFileName("nut-monitor-py3qt5") + self.__app.setDesktopFileName("nut-monitor-py3qt%d" % qt_version) except Exception as ex: pass self.__ui_file = self.__find_res_file( 'ui', "window1.ui" ) - self.__widgets["interface"] = PyQt6.uic.loadUi( self.__ui_file ) + self.__widgets["interface"] = qt_uic.loadUi( self.__ui_file ) self.__widgets["main_window"] = self.__widgets["interface"] self.__widgets["status_bar"] = self.__widgets["interface"].statusbar2 self.__widgets["ups_host_entry"] = self.__widgets["interface"].entry1 @@ -231,16 +289,16 @@ class interface : self.__widgets["ups_vars_tree"].setRootIsDecorated( False ) # Column 0 - store.setHeaderData( 0, Qt.Orientation.Horizontal, '' ) + store.setHeaderData( 0, QT_HORIZONTAL, '' ) # Column 1 - store.setHeaderData( 1, Qt.Orientation.Horizontal, _('Var name') ) + store.setHeaderData( 1, QT_HORIZONTAL, _('Var name') ) # Column 2 - store.setHeaderData( 2, Qt.Orientation.Horizontal, _('Value') ) + store.setHeaderData( 2, QT_HORIZONTAL, _('Value') ) self.__widgets["ups_vars_tree"].header().setStretchLastSection( True ) - self.__widgets["ups_vars_tree"].sortByColumn( 1, Qt.SortOrder.AscendingOrder ) + self.__widgets["ups_vars_tree"].sortByColumn( 1, QT_ASCENDING_ORDER ) self.__widgets["ups_vars_tree_store"] = store self.__widgets["ups_vars_tree"].setMinimumSize( 0, 50 ) @@ -547,7 +605,7 @@ class interface : #------------------------------------------------------------------- # This method is used to show/hide the main window when user clicks on the tray icon def tray_activated(self, reason): - if reason == QSystemTrayIcon.ActivationReason.Trigger: + if reason == TRAY_TRIGGER: win = self.__widgets["main_window"] if win.isVisible(): win.hide() @@ -673,19 +731,19 @@ class interface : # displays a dialog to enable user to select the name of the favorite def __gui_add_favorite( self, widget=None ) : dialog_ui_file = self.__find_res_file( 'ui', "dialog1.ui" ) - dialog = PyQt6.uic.loadUi( dialog_ui_file ) + dialog = qt_uic.loadUi( dialog_ui_file ) # Define interface callbacks actions def check_entry(val): if self.__gui_add_favorite_check_gui_fields(val): - dialog.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setEnabled( True ) + dialog.buttonBox.button(DIALOG_BUTTON_OK).setEnabled( True ) else: - dialog.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setEnabled( False ) + dialog.buttonBox.button(DIALOG_BUTTON_OK).setEnabled( False ) dialog.entry4.textChanged.connect( check_entry ) self.__widgets["main_window"].setEnabled( False ) rc = dialog.exec() - if rc == QDialog.DialogCode.Accepted : + if rc == DIALOG_ACCEPTED : fav_data = {} fav_data["host"] = self.__widgets["ups_host_entry"].text() fav_data["port"] = "%d" % self.__widgets["ups_port_entry"].value() @@ -708,7 +766,7 @@ class interface : # Method called when user wants to delete an entry from favorites def __gui_delete_favorite( self, widget=None ) : dialog_ui_file = self.__find_res_file( 'ui', "dialog2.ui" ) - dialog = PyQt6.uic.loadUi( dialog_ui_file ) + dialog = qt_uic.loadUi( dialog_ui_file ) # Remove the dummy combobox entry on list dialog.combobox2.removeItem( 0 ) @@ -725,10 +783,10 @@ class interface : fav_name = dialog.combobox2.currentText() self.__widgets["main_window"].setEnabled( True ) - if ( rc == QDialog.DialogCode.Accepted ) : + if ( rc == DIALOG_ACCEPTED ) : # Remove entry, show confirmation dialog - resp = QMessageBox.question( None, self.__widgets["main_window"].windowTitle(), _("Are you sure that you want to remove this favorite ?"), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes ) - if ( resp == QMessageBox.StandardButton.Yes ) : + resp = QMessageBox.question( None, self.__widgets["main_window"].windowTitle(), _("Are you sure that you want to remove this favorite ?"), MESSAGE_BUTTON_YES | MESSAGE_BUTTON_NO, MESSAGE_BUTTON_YES ) + if ( resp == MESSAGE_BUTTON_YES ) : del self.__favorites[ fav_name ] self.__gui_refresh_favorites_menu() self.__save_favorites() @@ -768,10 +826,10 @@ class interface : cmd = self.__ups_commands[ offset ].decode('ascii') self.__widgets["main_window"].setEnabled( False ) - resp = QMessageBox.question( None, self.__widgets["main_window"].windowTitle(), _("Are you sure that you want to send '%s' to the device ?") % cmd, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes ) + resp = QMessageBox.question( None, self.__widgets["main_window"].windowTitle(), _("Are you sure that you want to send '%s' to the device ?") % cmd, MESSAGE_BUTTON_YES | MESSAGE_BUTTON_NO, MESSAGE_BUTTON_YES ) self.__widgets["main_window"].setEnabled( True ) - if ( resp == QMessageBox.StandardButton.Yes ) : + if ( resp == MESSAGE_BUTTON_YES ) : try : self.__ups_handler.RunUPSCommand( self.__current_ups, cmd ) self.gui_status_message( _("Sent '{0}' command to {1}").format( cmd, self.__current_ups ) ) @@ -798,7 +856,7 @@ class interface : self.__widgets["main_window"], _("Edit Variable"), _("Enter a new value for the variable:\n\n{0} = {1} (current value)").format( ups_var.decode('ascii'), cur_val ), - QLineEdit.EchoMode.Normal, + LINE_EDIT_NORMAL, cur_val ) @@ -946,7 +1004,7 @@ class interface : self.__widgets["main_window"].adjustSize() dialog_ui_file = self.__find_res_file( 'ui', "aboutdialog1.ui" ) - dialog = PyQt6.uic.loadUi( dialog_ui_file ) + dialog = qt_uic.loadUi( dialog_ui_file ) dialog.icon.setPixmap( QPixmap( self.__find_icon_file() ) ) ### sys.stderr.write("gui_about_dialog(): dialog=%s\n" % str(dir(dialog))) ### sys.stderr.write("gui_about_dialog(): label=%s\n" % str(dir(dialog.label))) @@ -964,15 +1022,15 @@ class interface : credits_button = QPushButton( dialog ) credits_button.setText( _("C&redits") ) - credits_button.setIcon( dialog.style().standardIcon( QStyle.StandardPixmap.SP_MessageBoxInformation ) ) + credits_button.setIcon( dialog.style().standardIcon( STYLE_MESSAGE_INFO ) ) credits_button.clicked.connect( self.gui_about_credits ) licence_button = QPushButton( dialog ) licence_button.setText( _("&Licence") ) licence_button.clicked.connect( self.gui_about_licence ) - dialog.buttonBox.addButton( credits_button, QDialogButtonBox.ButtonRole.HelpRole ) - dialog.buttonBox.addButton( licence_button, QDialogButtonBox.ButtonRole.HelpRole ) + dialog.buttonBox.addButton( credits_button, DIALOG_HELP_ROLE ) + dialog.buttonBox.addButton( licence_button, DIALOG_HELP_ROLE ) self.__widgets["main_window"].setEnabled( False ) dialog.exec() @@ -1034,7 +1092,7 @@ along with this program. If not, see . self.__widgets["status_icon"].showMessage( "NUT Monitor", message, - icon if icon else QSystemTrayIcon.MessageIcon.Information, + icon if icon else TRAY_INFO, 5000 ) except Exception: @@ -1142,11 +1200,11 @@ along with this program. If not, see . icon = QIcon( icon_file ) item_icon = QStandardItem(icon, '') - item_icon.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemNeverHasChildren) + item_icon.setFlags(QT_ITEM_FLAGS) item_var_name = QStandardItem( k.decode('ascii') ) - item_var_name.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemNeverHasChildren) + item_var_name.setFlags(QT_ITEM_FLAGS) item_var_val = QStandardItem( v.decode('ascii') ) - item_var_val.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemNeverHasChildren) + item_var_val.setFlags(QT_ITEM_FLAGS) self.__widgets["ups_vars_tree_store"].appendRow( (item_icon, item_var_name, item_var_val) ) self.__widgets["ups_vars_tree"].resizeColumnToContents( 0 ) self.__widgets["ups_vars_tree"].resizeColumnToContents( 1 ) @@ -1378,7 +1436,7 @@ class gui_updater : ac = PyNUT.AuthConf.getAuthConf(host=host, port=port, user=login) if ac: ac = ac.clone() - if self.__widgets["ups_authentication_check"].isChecked() : + if self.__parent_class._interface__widgets["ups_authentication_check"].isChecked() : # If the user entered something, honor that. # Otherwise, re-use what we suggested. ac.user = login diff --git a/scripts/python/app/NUT-Monitor-py3qt5.in b/scripts/python/app/NUT-Monitor-py3qt5.in deleted file mode 100755 index 3937e9aaf7..0000000000 --- a/scripts/python/app/NUT-Monitor-py3qt5.in +++ /dev/null @@ -1,1447 +0,0 @@ -#!@PYTHON3@ -# -*- coding: utf-8 -*- - -# 2009-12-27 David Goncalves - Version 1.2 -# Total rewrite of NUT-Monitor to optimize GUI interaction. -# Added favorites support (saved to user's home) -# Added status icon on the notification area -# -# 2010-02-26 David Goncalves -# Added UPS vars display and the possibility to change values -# when user double-clicks on a RW var. -# -# 2010-05-01 David Goncalves -# Added support for PyNotify (if available) -# -# 2010-05-05 David Goncalves -# Added support for command line options -# -> --start-hidden -# -> --favorite -# -# NUT-Monitor now tries to detect if there is a NUT server -# on localhost and if there is 1 UPS, connects to it. -# -# 2010-10-06 David Goncalves - Version 1.3 -# Added localisation support -# -# 2015-02-14 Michal Fincham - Version 1.3.1 -# Corrected unsafe permissions on ~/.nut-monitor (Debian #777706) -# -# 2022-02-20 Luke Dashjr - Version 2.0 -# Port to Python 3 with PyQt5. -# -# 2023-11-27 Laurent Bigonville - Version 2.0.1 -# Set the DesktopFileName -# -# 2025-03-15 Jim Klimov - Version 2.0.2 -# Revise localisation, inject PACKAGE_VERSION and NUT_WEBSITE_BASE -# into the About dialog contents -# -# 2026-07-18 Jim Klimov and "developersorli" - Version 2.1.0 -# Added support for NUT authconf files (credentials, SSL) -# Added support for reconnections (client sleep, server restart) -# Fixed TAB/SHIFT-TAB focus change order through login page -# - -import PyQt5.uic -from PyQt5.QtCore import * -from PyQt5.QtGui import * -from PyQt5.QtWidgets import * - -import sys -import socket -import base64 -import os, os.path -import stat -import platform -import time -import threading -import optparse -import configparser -import locale -import gettext - -# Try local development/accompanying packaging first, -# then system installation -sys_path_orig = sys.path.copy() -try: - # Try "module" path relative to this program script: - mod_path = os.path.sep.join([ - os.path.dirname(os.path.abspath(os.path.realpath(__file__))), - "..", "module"]) - ### sys.stderr.write("[D] mod_path: %s\n" % mod_path) - if os.path.isfile(os.path.sep.join([mod_path, "PyNUT.py"])): - # Insert into the system path at index 1 to ensure that it - # resolves after the main script but before anything else: - sys.path.insert(1, mod_path) - ### sys.stderr.write("[D] sys.path: %s\n" % sys.path) - - # Try our chances with the default or augmented path - import PyNUT -except Exception as ignored: - # ModuleNotFoundError, path/parsing, et al - # Try our chances with the default path: - sys.path = sys_path_orig - - try: - # This path is for "default python interpreter", possibly customized - # and MIGHT be dist-packages or site-packages for a different python - # version. Keep it at lowest priority! - mod_path = "%s" % "@PYTHON_SITE_PACKAGES@" - if mod_path != "" and os.path.isdir(mod_path) and mod_path not in sys.path: - sys.path.append(mod_path) - - mod_path = "%s" % "@PYTHON2_SITE_PACKAGES@" - if mod_path != "" and os.path.isdir(mod_path) and mod_path not in sys.path: - sys.path.insert(1, mod_path) - - ### sys.stderr.write("[D2] sys.path: %s\n" % sys.path) - import PyNUT - - except Exception as ignored: - sys.path = sys_path_orig - ### sys.stderr.write("[D2] sys.path: %s\n" % sys.path) - import PyNUT - -# We would seek locale files relative to script dir -os.chdir(os.path.dirname(os.path.abspath(os.path.realpath(__file__)))) -# print(os.getcwd()) - -def plain_status_text( text ) : - document = QTextDocument() - document.setHtml( text ) - return document.toPlainText() - -def tooltip_value( value, unit="" ) : - if isinstance( value, bytes ) : - value = value.decode( 'ascii' ) - return "%s%s" % ( value, unit ) - -class interface : - - DESIRED_FAVORITES_DIRECTORY_MODE = 0o700 - - __quitting = False - __widgets = {} - __callbacks = {} - __favorites = {} - __favorites_file = None - __favorites_path = "" - __fav_menu_items = list() - __window_visible = True - __ui_file = None - __connected = False - __ups_handler = None - __ups_commands = None - __ups_vars = None - __ups_rw_vars = None - __gui_thread = None - __current_ups = None - - __login_manually_edited = False - __password_manually_edited = False - __login_suggested = False - __password_suggested = False - __auth_check_suggested = False - __auth_suggestions_suppressed = False - - def __init__( self, argv ) : - - # Before anything, parse command line options if any present... - opt_parser = optparse.OptionParser() - opt_parser.add_option( "-H", "--start-hidden", action="store_true", default=False, dest="hidden", help="Start iconified in tray" ) - opt_parser.add_option( "-F", "--favorite", dest="favorite", help="Load the specified favorite and connect to UPS" ) - - ( cmd_opts, args ) = opt_parser.parse_args() - - - self.__app = QApplication( argv ) - try: - self.__app.setDesktopFileName("nut-monitor-py3qt5") - except Exception as ex: - pass - - self.__ui_file = self.__find_res_file( 'ui', "window1.ui" ) - - self.__widgets["interface"] = PyQt5.uic.loadUi( self.__ui_file ) - self.__widgets["main_window"] = self.__widgets["interface"] - self.__widgets["status_bar"] = self.__widgets["interface"].statusbar2 - self.__widgets["ups_host_entry"] = self.__widgets["interface"].entry1 - self.__widgets["ups_port_entry"] = self.__widgets["interface"].spinbutton1 - self.__widgets["ups_refresh_button"] = self.__widgets["interface"].button1 - self.__widgets["ups_authentication_check"] = self.__widgets["interface"].checkbutton1 - self.__widgets["ups_authentication_frame"] = self.__widgets["interface"].hbox1 - self.__widgets["ups_authentication_login"] = self.__widgets["interface"].entry2 - self.__widgets["ups_authentication_password"] = self.__widgets["interface"].entry3 - self.__widgets["ups_list_combo"] = self.__widgets["interface"].combobox1 - self.__widgets["ups_commands_combo"] = self.__widgets["interface"].ups_commands_combo - self.__widgets["ups_commands_button"] = self.__widgets["interface"].button8 - self.__widgets["ups_connect"] = self.__widgets["interface"].button2 - self.__widgets["ups_disconnect"] = self.__widgets["interface"].button7 - self.__widgets["ups_params_box"] = self.__widgets["interface"].vbox6 - self.__widgets["ups_infos"] = self.__widgets["interface"].notebook1 - self.__widgets["ups_vars_tree"] = self.__widgets["interface"].treeview1 - self.__widgets["ups_vars_refresh"] = self.__widgets["interface"].button9 - self.__widgets["ups_status_image"] = self.__widgets["interface"].image1 - self.__widgets["ups_status_left"] = self.__widgets["interface"].label10 - self.__widgets["ups_status_right"] = self.__widgets["interface"].label11 - self.__widgets["ups_status_time"] = self.__widgets["interface"].label15 - self.__widgets["menu_favorites_root"] = self.__widgets["interface"].menu2 - self.__widgets["menu_favorites"] = self.__widgets["interface"].menu2 - self.__widgets["menu_favorites_add"] = self.__widgets["interface"].menuitem4 - self.__widgets["menu_favorites_del"] = self.__widgets["interface"].menuitem5 - self.__widgets["progress_battery_charge"] = self.__widgets["interface"].progressbar1 - self.__widgets["progress_battery_load"] = self.__widgets["interface"].progressbar2 - - # Create the tray icon and connect it to the show/hide method... - self.__widgets["status_icon"] = QSystemTrayIcon( QIcon( self.__find_res_file( "pixmaps", "on_line.png" ) ) ) - self.__widgets["status_icon"].setVisible( True ) - self.__widgets["status_icon"].activated.connect( self.tray_activated ) - - self.__widgets["ups_status_image"].setPixmap( QPixmap( self.__find_res_file( "pixmaps", "on_line.png" ) ) ) - - # Connect interface callbacks actions - self.__widgets["main_window"].destroyed.connect( self.quit ) - self.__widgets["interface"].imagemenuitem1.triggered.connect( self.gui_about_dialog ) - self.__widgets["interface"].imagemenuitem5.triggered.connect( self.quit ) - self.__widgets["ups_host_entry"].textChanged.connect( self.__check_gui_fields ) - self.__widgets["ups_authentication_login"].textChanged.connect( self.__on_login_changed ) - self.__widgets["ups_authentication_password"].textChanged.connect( self.__on_password_changed ) - self.__widgets["ups_authentication_check"].toggled.connect( self.__on_auth_check_changed ) - self.__widgets["ups_port_entry"].valueChanged.connect( self.__check_gui_fields ) - self.__widgets["ups_port_entry"].lineEdit().textChanged.connect( self.__check_gui_fields ) - self.__widgets["ups_refresh_button"].clicked.connect( self.__update_ups_list ) - self.__widgets["ups_connect"].clicked.connect( self.connect_to_ups ) - self.__widgets["ups_disconnect"].clicked.connect( self.disconnect_from_ups ) - self.__widgets["ups_vars_refresh"].clicked.connect( self.__gui_update_ups_vars_view ) - self.__widgets["menu_favorites_add"].triggered.connect( self.__gui_add_favorite ) - self.__widgets["menu_favorites_del"].triggered.connect( self.__gui_delete_favorite ) - self.__widgets["ups_vars_tree"].doubleClicked.connect( self.__gui_ups_vars_selected ) - - # Remove the dummy combobox entry on UPS List and Commands - self.__widgets["ups_list_combo"].removeItem( 0 ) - - # Set UPS vars treeview properties ----------------------------- - store = QStandardItemModel( 0, 3, self.__widgets["ups_vars_tree"] ) - self.__widgets["ups_vars_tree"].setModel( store ) - self.__widgets["ups_vars_tree"].setHeaderHidden( False ) - self.__widgets["ups_vars_tree"].setRootIsDecorated( False ) - - # Column 0 - store.setHeaderData( 0, Qt.Horizontal, '' ) - - # Column 1 - store.setHeaderData( 1, Qt.Horizontal, _('Var name') ) - - # Column 2 - store.setHeaderData( 2, Qt.Horizontal, _('Value') ) - self.__widgets["ups_vars_tree"].header().setStretchLastSection( True ) - - self.__widgets["ups_vars_tree"].sortByColumn( 1, Qt.AscendingOrder ) - self.__widgets["ups_vars_tree_store"] = store - - self.__widgets["ups_vars_tree"].setMinimumSize( 0, 50 ) - #--------------------------------------------------------------- - - # UPS Commands combo box creation ------------------------------ - ups_commands_height = self.__widgets["ups_commands_combo"].size().height() * 2 - self.__widgets["ups_commands_combo"].setMinimumSize(0, ups_commands_height) - self.__widgets["ups_commands_combo"].setCurrentIndex( 0 ) - - self.__widgets["ups_commands_button"].setMinimumSize(0, ups_commands_height) - self.__widgets["ups_commands_button"].clicked.connect( self.__gui_send_ups_command ) - - self.__widgets["ups_commands_combo_store"] = self.__widgets["ups_commands_combo"] - #--------------------------------------------------------------- - - # Set initial tab order - QWidget.setTabOrder( self.__widgets["ups_host_entry"], self.__widgets["ups_port_entry"] ) - QWidget.setTabOrder( self.__widgets["ups_port_entry"], self.__widgets["ups_list_combo"] ) - QWidget.setTabOrder( self.__widgets["ups_list_combo"], self.__widgets["ups_refresh_button"] ) - QWidget.setTabOrder( self.__widgets["ups_refresh_button"], self.__widgets["ups_authentication_check"] ) - QWidget.setTabOrder( self.__widgets["ups_authentication_check"], self.__widgets["ups_authentication_login"] ) - QWidget.setTabOrder( self.__widgets["ups_authentication_login"], self.__widgets["ups_authentication_password"] ) - QWidget.setTabOrder( self.__widgets["ups_authentication_password"], self.__widgets["ups_connect"] ) - QWidget.setTabOrder( self.__widgets["ups_connect"], self.__widgets["ups_host_entry"] ) - - # Set initial focus to host field - self.__widgets["ups_host_entry"].setFocus() - - self.gui_init_unconnected() - - # Load global authentication configuration - PyNUT.AuthConf.readAuthConfFile(verbose_missing_hinted=True) - # and populate fields from global defaults if any - ac = PyNUT.AuthConf.getAuthConf() - if ac: - if ac.user: - self.__widgets["ups_authentication_login"].setText(ac.user) - self.__set_suggested_style( self.__widgets["ups_authentication_login"], True ) - - # (Re-)Set these AFTER on*changed handlers might - # by default assume the field was edited by user! - self.__login_manually_edited = False - self.__login_suggested = True - - if ac.password: - self.__widgets["ups_authentication_password"].setText(ac.password) - self.__set_suggested_style( self.__widgets["ups_authentication_password"], True ) - - # (Re-)Set these AFTER on*changed handlers might - # by default assume the field was edited by user! - self.__password_manually_edited = False - self.__password_suggested = True - - self.__auth_check_suggested = self.__login_suggested and self.__password_suggested - self.__widgets["ups_authentication_check"].setChecked(self.__auth_check_suggested) - self.__set_suggested_style( self.__widgets["ups_authentication_check"], self.__auth_check_suggested ) - - if ( cmd_opts.hidden != True ) : - self.__widgets["main_window"].show() - - # Define favorites path and load favorites - if ( platform.system() == "Linux" ) : - self.__favorites_path = os.path.join( os.environ.get("HOME"), ".nut-monitor" ) - elif ( platform.system() == "Windows" ) : - self.__favorites_path = os.path.join( os.environ.get("USERPROFILE"), "Application Data", "NUT-Monitor" ) - - self.__favorites_file = os.path.join( self.__favorites_path, "favorites.ini" ) - self.__parse_favorites() - - self.gui_status_message( _("Welcome to NUT Monitor") ) - - if ( cmd_opts.favorite != None ) : - if ( cmd_opts.favorite in self.__favorites ) : - self.__gui_load_favorite( fav_name=cmd_opts.favorite ) - self.connect_to_ups() - else : - # Try to scan localhost for available ups and connect to it if there is only one - self.__widgets["ups_host_entry"].setText( "localhost" ) - self.__update_ups_list() - if self.__widgets["ups_list_combo"].count() == 1: - self.connect_to_ups() - - def exec( self ) : - self.__app.exec() - - def __find_res_file( self, ftype, filename ) : - # print("%s ~ %s => '%s' '%s'\n" % (os.getcwd(), os.path.dirname( sys.argv[0] ), ftype, filename)) - filename = os.path.join( ftype, filename ) - # We would be in script dir - if os.path.exists(filename): - return filename - # TODO: Skip checking application directory if installed - path = os.path.join( os.path.dirname( sys.argv[0] ), filename ) - if os.path.exists(path): - return path - path = QStandardPaths.locate(QStandardPaths.AppDataLocation, filename) - if os.path.exists(path): - return path - raise RuntimeError("Cannot find %s resource %s" % (ftype, filename)) - - def __find_icon_file( self ) : - filename = 'nut-monitor.png' - - # TODO: Skip checking application directory if installed - path = os.path.join( "icons", "256x256", filename ) - if os.path.exists(path): - return path - - path = os.path.join( os.path.dirname( sys.argv[0] ), "icons", "256x256", filename ) - if os.path.exists(path): - return path - - # Normally icons should be installed to OS location by packaging: - path = QStandardPaths.locate(QStandardPaths.GenericDataLocation, os.path.join( "icons", "hicolor", "256x256", "apps", filename ) ) - if os.path.exists(path): - return path - - # Fall back to NUT-specific area where `make install` might put them, - # e.g. /usr/share/nut/nut-monitor/icons/... or some such, if not the - # same location as checked in first attempt above: - path = QStandardPaths.locate(QStandardPaths.AppDataLocation, os.path.join( "icons", "hicolor", "256x256", "apps", filename ) ) - if os.path.exists(path): - return path - - # No banana! - raise RuntimeError("Cannot find %s resource %s" % ('icon', filename)) - - def __set_suggested_style( self, widget, is_suggested ) : - if is_suggested : - # gray-yellowish background - widget.setStyleSheet( "background-color: #fcfce0;" ) - else : - widget.setStyleSheet( "" ) - - def __on_login_changed( self, text ) : - if not self.__widgets["ups_authentication_login"].isEnabled(): - return - self.__login_manually_edited = True - self.__login_suggested = False - self.__set_suggested_style( self.__widgets["ups_authentication_login"], False ) - self.__check_gui_fields() - - def __on_password_changed( self, text ) : - if not self.__widgets["ups_authentication_password"].isEnabled(): - return - self.__password_manually_edited = True - self.__password_suggested = False - self.__set_suggested_style( self.__widgets["ups_authentication_password"], False ) - self.__check_gui_fields() - - def __on_auth_check_changed( self, checked ) : - self.__auth_check_suggested = False - self.__auth_suggestions_suppressed = not checked - if not checked : - # If user unchecks, we clear suggestions so it doesn't immediately re-check - if self.__login_suggested : - self.__login_suggested = False - self.__widgets["ups_authentication_login"].blockSignals(True) - self.__widgets["ups_authentication_login"].setText("") - self.__widgets["ups_authentication_login"].blockSignals(False) - self.__login_manually_edited = False - self.__set_suggested_style( self.__widgets["ups_authentication_login"], False ) - if self.__password_suggested : - self.__password_suggested = False - self.__widgets["ups_authentication_password"].blockSignals(True) - self.__widgets["ups_authentication_password"].setText("") - self.__widgets["ups_authentication_password"].blockSignals(False) - self.__password_manually_edited = False - self.__set_suggested_style( self.__widgets["ups_authentication_password"], False ) - self.__check_gui_fields() - - # Check if correct fields are filled to enable connection to the UPS - def __check_gui_fields( self, widget=None ) : - host = self.__widgets["ups_host_entry"].text() - try: - port = int( self.__widgets["ups_port_entry"].value() ) - except: - port = 3493 - - login = self.__widgets["ups_authentication_login"].text() - ac_no_user = PyNUT.AuthConf.getAuthConf(host=host, port=port) - - # If not manually edited and was suggested, check if it still matches authconf for current host/port/user - if not self.__login_manually_edited and not self.__auth_suggestions_suppressed: - if self.__login_suggested: - if not ac_no_user or not ac_no_user.user: - self.__widgets["ups_authentication_login"].blockSignals(True) - self.__widgets["ups_authentication_login"].setText("") - self.__widgets["ups_authentication_login"].blockSignals(False) - self.__set_suggested_style( self.__widgets["ups_authentication_login"], False ) - self.__login_manually_edited = False - self.__login_suggested = False - elif ac_no_user.user != self.__widgets["ups_authentication_login"].text(): - self.__widgets["ups_authentication_login"].blockSignals(True) - self.__widgets["ups_authentication_login"].setText(ac_no_user.user) - self.__widgets["ups_authentication_login"].blockSignals(False) - self.__set_suggested_style( self.__widgets["ups_authentication_login"], True ) - self.__login_manually_edited = False - self.__login_suggested = True - elif ac_no_user and ac_no_user.user: - self.__widgets["ups_authentication_login"].blockSignals(True) - self.__widgets["ups_authentication_login"].setText(ac_no_user.user) - self.__widgets["ups_authentication_login"].blockSignals(False) - self.__set_suggested_style( self.__widgets["ups_authentication_login"], True ) - self.__login_manually_edited = False - self.__login_suggested = True - - # Now re-evaluate password suggestion based on potentially updated login - login = self.__widgets["ups_authentication_login"].text() - ac = PyNUT.AuthConf.getAuthConf(user=login, host=host, port=port) - # Look for direct hits - ac_user_match = PyNUT.AuthConf.findAuthConf(user=login, host=host, port=port) - if not ((ac_no_user and ac_no_user.user == login) or - (ac_user_match and ac_user_match.section == ac.section)): - ac = None - - # Ensure that if the entered username doesn't match anything, we clear suggested password - # (even if login itself was manually edited) - if not self.__password_manually_edited and not self.__auth_suggestions_suppressed: - if not ac or not ac.password: - if self.__password_suggested: - self.__widgets["ups_authentication_password"].blockSignals(True) - self.__widgets["ups_authentication_password"].setText("") - self.__widgets["ups_authentication_password"].blockSignals(False) - self.__set_suggested_style( self.__widgets["ups_authentication_password"], False ) - self.__password_manually_edited = False - self.__password_suggested = False - elif self.__password_suggested and ac.password != self.__widgets["ups_authentication_password"].text(): - # If it was suggested but now matches a different password (e.g. for a different user) - self.__widgets["ups_authentication_password"].blockSignals(True) - self.__widgets["ups_authentication_password"].setText(ac.password) - self.__widgets["ups_authentication_password"].blockSignals(False) - self.__set_suggested_style( self.__widgets["ups_authentication_password"], True ) - self.__password_manually_edited = False - self.__password_suggested = True - elif not self.__password_suggested and ac and ac.password: - # If not suggested yet but we found a match - self.__widgets["ups_authentication_password"].blockSignals(True) - self.__widgets["ups_authentication_password"].setText(ac.password) - self.__widgets["ups_authentication_password"].blockSignals(False) - self.__set_suggested_style( self.__widgets["ups_authentication_password"], True ) - self.__password_manually_edited = False - self.__password_suggested = True - - - # Automatically select authentication only for a complete credential suggestion. - if (self.__login_suggested and self.__password_suggested and - not self.__widgets["ups_authentication_check"].isChecked()): - self.__widgets["ups_authentication_check"].blockSignals(True) - self.__widgets["ups_authentication_check"].setChecked(True) - self.__widgets["ups_authentication_check"].blockSignals(False) - self.__auth_check_suggested = True - - # Only retract an automatic selection. Editing either credential transfers - # control to the user and keeps authentication selected. - if self.__auth_check_suggested and not (self.__login_suggested and self.__password_suggested): - if self.__login_manually_edited or self.__password_manually_edited: - self.__auth_check_suggested = False - else: - self.__widgets["ups_authentication_check"].blockSignals(True) - self.__widgets["ups_authentication_check"].setChecked(False) - self.__widgets["ups_authentication_check"].blockSignals(False) - self.__auth_check_suggested = False - - self.__set_suggested_style( self.__widgets["ups_authentication_check"], self.__auth_check_suggested ) - - # If UPS list contains something, clear it - if self.__widgets["ups_list_combo"].currentIndex() != -1 : - self.__widgets["ups_list_combo"].clear() - self.__widgets["ups_connect"].setEnabled( False ) - self.__widgets["menu_favorites_add"].setEnabled( False ) - - # Host/Port selection - if len( self.__widgets["ups_host_entry"].text() ) > 0 : - sensitive = True - - # If authentication is selected, check that we have a login and password - if self.__widgets["ups_authentication_check"].isChecked() : - if len( self.__widgets["ups_authentication_login"].text() ) == 0 : - sensitive = False - - if len( self.__widgets["ups_authentication_password"].text() ) == 0 : - sensitive = False - - self.__widgets["ups_refresh_button"].setEnabled( sensitive ) - if not sensitive : - self.__widgets["ups_connect"].setEnabled( False ) - self.__widgets["menu_favorites_add"].setEnabled( False ) - else : - self.__widgets["ups_refresh_button"].setEnabled( False ) - self.__widgets["ups_connect"].setEnabled( False ) - self.__widgets["menu_favorites_add"].setEnabled( False ) - - # Use authentication fields... - if self.__widgets["ups_authentication_check"].isChecked() : - self.__widgets["ups_authentication_frame"].setEnabled( True ) - else : - self.__widgets["ups_authentication_frame"].setEnabled( False ) - - #------------------------------------------------------------------- - # This method is used to show/hide the main window when user clicks on the tray icon - def tray_activated( self, widget=None, data=None ) : - if self.__window_visible : - self.__widgets["main_window"].hide() - else : - self.__widgets["main_window"].show() - - self.__window_visible = not self.__window_visible - - #------------------------------------------------------------------- - # Change the status icon and tray icon - def change_status_icon( self, icon="on_line", blink=False ) : - self.__widgets["status_icon"].setIcon( QIcon( self.__find_res_file( "pixmaps", "%s.png" % icon ) ) ) - self.__widgets["ups_status_image"].setPixmap( QPixmap( self.__find_res_file( "pixmaps", "%s.png" % icon ) ) ) - # TODO self.__widgets["status_icon"].set_blinking( blink ) - - #------------------------------------------------------------------- - # This method connects to the NUT server and retrieve availables UPSes - # using connection parameters (host, port, login, pass...) - def __update_ups_list( self, widget=None ) : - - host = self.__widgets["ups_host_entry"].text() - port = int( self.__widgets["ups_port_entry"].value() ) - login = None - password = None - - ac = PyNUT.AuthConf.getAuthConf(host=host, port=port) - if ac: - # Check for mismatch if fields are already filled - curr_login = self.__widgets["ups_authentication_login"].text() - curr_pass = self.__widgets["ups_authentication_password"].text() - - mismatch = False - if self.__login_manually_edited and ac.user and curr_login != ac.user: - mismatch = True - if self.__password_manually_edited and ac.password and curr_pass != ac.password: - mismatch = True - - if mismatch: - self.gui_status_message( _("Note: interactive login/password differs from nutauth.conf") ) - else: - if not self.__login_manually_edited and not self.__password_manually_edited: - if ac.user: - self.__widgets["ups_authentication_login"].setText(ac.user) - self.__set_suggested_style( self.__widgets["ups_authentication_login"], True ) - - # (Re-)Set these AFTER on*changed handlers might - # by default assume the field was edited by user! - self.__login_manually_edited = False - self.__login_suggested = True - - if ac.password: - self.__widgets["ups_authentication_password"].setText(ac.password) - self.__set_suggested_style( self.__widgets["ups_authentication_password"], True ) - self.__password_manually_edited = False - self.__password_suggested = True - - self.__widgets["ups_authentication_check"].setChecked(self.__login_suggested and self.__password_suggested) - self.__auth_check_suggested = self.__login_suggested and self.__password_suggested - self.__set_suggested_style( self.__widgets["ups_authentication_check"], self.__auth_check_suggested ) - - if self.__widgets["ups_authentication_check"].isChecked() : - login = self.__widgets["ups_authentication_login"].text() - password = self.__widgets["ups_authentication_password"].text() - - try : - # ac was already looked up for host/port above, - # and it may also be about SSL etc. - if ac: - ac = ac.clone() - if self.__widgets["ups_authentication_check"].isChecked() : - # If the user entered something, honor that. - # Otherwise, re-use what we suggested. - ac.user = login - ac.password = password - else: - ac.user = None - ac.password = None - - nut_handler = PyNUT.PyNUTClient.from_authconf( ac ) - else: - nut_handler = PyNUT.PyNUTClient( host=host, port=port, login=login, password=password ) - upses = nut_handler.GetUPSList() - - ups_list = list(key.decode('ascii') for key in upses.keys()) - ups_list.sort() - - # If UPS list contains something, clear it - self.__widgets["ups_list_combo"].clear() - - for current in ups_list : - self.__widgets["ups_list_combo"].addItem( current ) - - self.__widgets["ups_list_combo"].setCurrentIndex( 0 ) - - self.__widgets["ups_connect"].setEnabled( True ) - self.__widgets["menu_favorites_add"].setEnabled( True ) - - self.gui_status_message( _("Found {0} devices on {1}").format( len( ups_list ), host ) ) - - except : - error_msg = _("Error connecting to '{0}' ({1})").format( host, sys.exc_info()[1] ) - self.gui_status_message( error_msg ) - - #------------------------------------------------------------------- - # Quit program - def quit( self, widget=None ) : - if self.__quitting: - return - self.__quitting = True - - # If we are connected to an UPS, disconnect first... - if self.__connected : - try: - self.gui_status_message( _("Disconnecting from device") ) - self.disconnect_from_ups() - except (RuntimeError, AttributeError): - pass # widgets or attributes may already be deleted during shutdown - - self.__app.quit() - - #------------------------------------------------------------------- - # Method called when user wants to add a new favorite entry. It - # displays a dialog to enable user to select the name of the favorite - def __gui_add_favorite( self, widget=None ) : - dialog_ui_file = self.__find_res_file( 'ui', "dialog1.ui" ) - dialog = PyQt5.uic.loadUi( dialog_ui_file ) - - # Define interface callbacks actions - def check_entry(val): - if self.__gui_add_favorite_check_gui_fields(val): - dialog.buttonBox.button(QDialogButtonBox.Ok).setEnabled( True ) - else: - dialog.buttonBox.button(QDialogButtonBox.Ok).setEnabled( False ) - dialog.entry4.textChanged.connect( check_entry ) - - self.__widgets["main_window"].setEnabled( False ) - rc = dialog.exec() - if rc == QDialog.Accepted : - fav_data = {} - fav_data["host"] = self.__widgets["ups_host_entry"].text() - fav_data["port"] = "%d" % self.__widgets["ups_port_entry"].value() - fav_data["ups"] = self.__widgets["ups_list_combo"].currentText() - fav_data["auth"] = self.__widgets["ups_authentication_check"].isChecked() - if fav_data["auth"] : - fav_data["login"] = self.__widgets["ups_authentication_login"].text() - fav_data["password"] = base64.b64encode( self.__widgets["ups_authentication_password"].text().encode('ascii') ).decode('ascii') - - fav_name = dialog.entry4.text() - self.__favorites[ fav_name ] = fav_data - self.__gui_refresh_favorites_menu() - - # Save all favorites - self.__save_favorites() - - self.__widgets["main_window"].setEnabled( True ) - - #------------------------------------------------------------------- - # Method called when user wants to delete an entry from favorites - def __gui_delete_favorite( self, widget=None ) : - dialog_ui_file = self.__find_res_file( 'ui', "dialog2.ui" ) - dialog = PyQt5.uic.loadUi( dialog_ui_file ) - - # Remove the dummy combobox entry on list - dialog.combobox2.removeItem( 0 ) - - favs = list(self.__favorites.keys()) - favs.sort() - for current in favs : - dialog.combobox2.addItem( current ) - - dialog.combobox2.setCurrentIndex( 0 ) - - self.__widgets["main_window"].setEnabled( False ) - rc = dialog.exec() - fav_name = dialog.combobox2.currentText() - self.__widgets["main_window"].setEnabled( True ) - - if ( rc == QDialog.Accepted ) : - # Remove entry, show confirmation dialog - resp = QMessageBox.question( None, self.__widgets["main_window"].windowTitle(), _("Are you sure that you want to remove this favorite ?"), QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes ) - - if ( resp == QMessageBox.Yes ) : - del self.__favorites[ fav_name ] - self.__gui_refresh_favorites_menu() - self.__save_favorites() - self.gui_status_message( _("Removed favorite '%s'") % fav_name ) - - #------------------------------------------------------------------- - # Method called when user selects a favorite from the favorites menu - def __gui_load_favorite( self, fav_name="" ) : - - if ( fav_name in self.__favorites ) : - # If auth is activated, process it before other fields to avoir weird - # reactions with the 'check_gui_fields' function. - if ( self.__favorites[fav_name].get("auth", False ) ) : - self.__widgets["ups_authentication_check"].setChecked( True ) - password = base64.b64decode( self.__favorites[fav_name].get("password","") ).decode('ascii') - self.__widgets["ups_authentication_login"].setText( self.__favorites[fav_name].get("login","") ) - self.__widgets["ups_authentication_password"].setText( password ) - - self.__widgets["ups_host_entry"].setText( self.__favorites[fav_name].get("host","") ) - self.__widgets["ups_port_entry"].setValue( int( self.__favorites[fav_name].get( "port", 3493 ) ) ) - - # Clear UPS list and add current UPS name - self.__widgets["ups_list_combo"].clear() - - self.__widgets["ups_list_combo"].addItem( self.__favorites[fav_name].get("ups","") ) - self.__widgets["ups_list_combo"].setCurrentIndex( 0 ) - - # Activate the connect button - self.__widgets["ups_connect"].setEnabled( True ) - - self.gui_status_message( _("Loaded '%s'") % fav_name ) - - #------------------------------------------------------------------- - # Send the selected command to the UPS - def __gui_send_ups_command( self, widget=None ) : - offset = self.__widgets["ups_commands_combo"].currentIndex() - cmd = self.__ups_commands[ offset ].decode('ascii') - - self.__widgets["main_window"].setEnabled( False ) - resp = QMessageBox.question( None, self.__widgets["main_window"].windowTitle(), _("Are you sure that you want to send '%s' to the device ?") % cmd, QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes ) - self.__widgets["main_window"].setEnabled( True ) - - if ( resp == QMessageBox.Yes ) : - try : - self.__ups_handler.RunUPSCommand( self.__current_ups, cmd ) - self.gui_status_message( _("Sent '{0}' command to {1}").format( cmd, self.__current_ups ) ) - - except : - self.gui_status_message( _("Failed to send '{0}' ({1})").format( cmd, sys.exc_info()[1] ) ) - - #------------------------------------------------------------------- - # Method called when user clicks on the UPS vars treeview. If the user - # performs a double click on a RW var, the GUI shows the update var dialog. - def __gui_ups_vars_selected( self, index ) : - if True : - model = self.__widgets["ups_vars_tree_store"] - try : - ups_var = model.data( index.siblingAtColumn(1) ).encode('ascii') - if ( ups_var in self.__ups_rw_vars ) : - # The selected var is RW, then we can show the update dialog - - cur_val = self.__ups_rw_vars.get(ups_var).decode('ascii') - - self.__widgets["main_window"].setEnabled( False ) - new_val, rc = QInputDialog.getText( None, self.__widgets["main_window"].windowTitle(), _("Enter a new value for the variable.

{0} = {1} (current value)").format( ups_var, cur_val), QLineEdit.Normal, cur_val ) - self.__widgets["main_window"].setEnabled( True ) - - if ( rc ) : - try : - self.__ups_handler.SetRWVar( ups=self.__current_ups, var=ups_var.decode('ascii'), value=new_val ) - self.gui_status_message( _("Updated variable on %s") % self.__current_ups ) - - # Change the value on the local dict to update the GUI - new_val = new_val.encode('ascii') - self.__ups_vars[ups_var] = new_val - self.__ups_rw_vars[ups_var] = new_val - self.__gui_update_ups_vars_view() - - except : - error_msg = _("Error updating variable on '{0}' ({1})").format( self.__current_ups, sys.exc_info()[1] ) - self.gui_status_message( error_msg ) - - else : - # User cancelled modification... - error_msg = _("No variable modified on %s - User cancelled") % self.__current_ups - self.gui_status_message( error_msg ) - - except : - # Failed to get information from the treeview... skip action - pass - - #------------------------------------------------------------------- - # Refresh the content of the favorites menu according to the defined favorites - def __gui_refresh_favorites_menu( self ) : - for current in self.__fav_menu_items : - self.__widgets["menu_favorites"].removeAction(current) - - self.__fav_menu_items = list() - - items = list(self.__favorites.keys()) - items.sort() - - for current in items : - menu_item = QAction( current ) - self.__fav_menu_items.append( menu_item ) - self.__widgets["menu_favorites"].addAction( menu_item ) - - menu_item.triggered.connect( lambda: self.__gui_load_favorite( current ) ) - - if len( items ) > 0 : - self.__widgets["menu_favorites_del"].setEnabled( True ) - else : - self.__widgets["menu_favorites_del"].setEnabled( False ) - - #------------------------------------------------------------------- - # In 'add favorites' dialog, this method compares the content of the - # text widget representing the name of the new favorite with existing - # ones. If they match, the 'add' button will be set to non sensitive - # to avoid creating entries with the same name. - def __gui_add_favorite_check_gui_fields( self, fav_name ) : - if ( len( fav_name ) > 0 ) and ( fav_name not in list(self.__favorites.keys()) ) : - return True - else : - return False - - #------------------------------------------------------------------- - # Load and parse favorites - def __parse_favorites( self ) : - - if ( not os.path.exists( self.__favorites_file ) ) : - # There is no favorites files, do nothing - return - - try : - if ( not stat.S_IMODE( os.stat( self.__favorites_path ).st_mode ) == self.DESIRED_FAVORITES_DIRECTORY_MODE ) : # unsafe pre-1.2 directory found - os.chmod( self.__favorites_path, self.DESIRED_FAVORITES_DIRECTORY_MODE ) - - conf = configparser.ConfigParser() - conf.read( self.__favorites_file ) - for current in conf.sections() : - # Check if mandatory fields are present - if ( conf.has_option( current, "host" ) and conf.has_option( current, "ups" ) ) : - # Valid entry found, add it to the list - fav_data = {} - fav_data["host"] = conf.get( current, "host" ) - fav_data["ups"] = conf.get( current, "ups" ) - - if ( conf.has_option( current, "port" ) ) : - fav_data["port"] = conf.get( current, "port" ) - else : - fav_data["port"] = "3493" - - # If auth is defined the section must have login and pass defined - if ( conf.has_option( current, "auth" ) ) : - if( conf.has_option( current, "login" ) and conf.has_option( current, "password" ) ) : - # Add the entry - fav_data["auth"] = conf.getboolean( current, "auth" ) - fav_data["login"] = conf.get( current, "login" ) - - try : - fav_data["password"] = base64.decodebytes( conf.get( current, "password" ).encode('ascii') ).decode('ascii') - - except : - # If the password is not in base64, let the field empty - print(( _("Error parsing favorites, password for '%s' is not in base64\nSkipping password for this entry") % current )) - fav_data["password"] = "" - else : - fav_data["auth"] = False - - self.__favorites[current] = fav_data - self.__gui_refresh_favorites_menu() - - except : - self.gui_status_message( _("Error while parsing favorites file (%s)") % sys.exc_info()[1] ) - - #------------------------------------------------------------------- - # Save favorites to the defined favorites file using ini format - def __save_favorites( self ) : - - # If path does not exists, try to create it - if ( not os.path.exists( self.__favorites_file ) ) : - try : - os.makedirs( self.__favorites_path, mode=self.DESIRED_FAVORITES_DIRECTORY_MODE, exist_ok=True ) - except : - self.gui_status_message( _("Error while creating configuration folder (%s)") % sys.exc_info()[1] ) - - save_conf = configparser.ConfigParser() - for current in list(self.__favorites.keys()) : - save_conf.add_section( current ) - for k, v in self.__favorites[ current ].items() : - if isinstance( v, bool ) : - v = str( v ) - save_conf.set( current, k, v ) - - try : - fh = open( self.__favorites_file, "w" ) - save_conf.write( fh ) - fh.close() - self.gui_status_message( _("Saved favorites...") ) - - except : - self.gui_status_message( _("Error while saving favorites (%s)") % sys.exc_info()[1] ) - - #------------------------------------------------------------------- - # Display the about dialog - def gui_about_dialog( self, widget=None ) : - self.__widgets["main_window"].adjustSize() - dialog_ui_file = self.__find_res_file( 'ui', "aboutdialog1.ui" ) - - dialog = PyQt5.uic.loadUi( dialog_ui_file ) - dialog.icon.setPixmap( QPixmap( self.__find_icon_file() ) ) - ### sys.stderr.write("gui_about_dialog(): dialog=%s\n" % str(dir(dialog))) - ### sys.stderr.write("gui_about_dialog(): label=%s\n" % str(dir(dialog.label))) - ### sys.stderr.write("gui_about_dialog(): text=%s\n" % str(dir(dialog.label.text))) - s = dialog.label.text() - ### sys.stderr.write("gui_about_dialog(): s (original) ='%s'\n" % s) - s = _(s) - ### sys.stderr.write("gui_about_dialog(): s (localized) ='%s'\n" % s) - s = (s - .replace('%s%s%s' % ("@", "PACKAGE_VERSION", "@"), '@PACKAGE_VERSION@') - .replace('%s%s%s' % ("@", "NUT_WEBSITE_BASE", "@"), '@NUT_WEBSITE_BASE@') - ) - ### sys.stderr.write("gui_about_dialog(): s (rewritten) ='%s'\n" % s) - dialog.label.setText(s) - - credits_button = QPushButton( dialog ) - credits_button.setText( _("C&redits") ) - credits_button.setIcon( dialog.style().standardIcon( QStyle.SP_MessageBoxInformation ) ) - credits_button.clicked.connect( self.gui_about_credits ) - - licence_button = QPushButton( dialog ) - licence_button.setText( _("&Licence") ) - licence_button.clicked.connect( self.gui_about_licence ) - - dialog.buttonBox.addButton( credits_button, QDialogButtonBox.HelpRole ) - dialog.buttonBox.addButton( licence_button, QDialogButtonBox.HelpRole ) - - self.__widgets["main_window"].setEnabled( False ) - dialog.exec() - self.__widgets["main_window"].setEnabled( True ) - - def gui_about_credits( self ) : - QMessageBox.about( None, _("Credits"), _(""" -Written by: -David Goncalves - -Translated by: -David Goncalves - Français -Daniele Pezzini - Italiano -Alexey Rodionov - Russian - """).strip() ) - - def gui_about_licence( self ) : - QMessageBox.about( None, _("Licence"), _(""" -Copyright (C) 2010 David Goncalves -Copyright (C) since 2010 by NUT Community - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . - """).strip() ) - - #------------------------------------------------------------------- - # Display a message on the status bar. The message is also set as - # tooltip to enable users to see long messages. - def gui_status_message( self, msg="" ) : - try: - text = msg - if self.__widgets.get("status_bar"): - self.__widgets["status_bar"].showMessage( text.replace("\n", "") ) - self.__widgets["status_bar"].setToolTip( text ) - except Exception: - pass - - #------------------------------------------------------------------- - # Display a notification using QSystemTrayIcon with an optional icon - def gui_status_notification( self, message="", icon_file="" ) : - try: - if not self.__widgets.get("status_icon"): - return - if ( icon_file != "" ) : - icon = QIcon( os.path.abspath( self.__find_res_file( "pixmaps", icon_file ) ) ) - else : - icon = None - - self.__widgets["status_icon"].showMessage( "NUT Monitor", message, icon ) - except Exception: - pass - - #------------------------------------------------------------------- - # Connect to the selected UPS using parameters (host,port,login,pass) - def connect_to_ups( self, widget=None ) : - - host = self.__widgets["ups_host_entry"].text() - port = int( self.__widgets["ups_port_entry"].value() ) - login = None - password = None - - if self.__widgets["ups_authentication_check"].isChecked() : - login = self.__widgets["ups_authentication_login"].text() - password = self.__widgets["ups_authentication_password"].text() - - ac = PyNUT.AuthConf.getAuthConf(host=host, port=port, user=login) - try : - if ac: - ac = ac.clone() - if self.__widgets["ups_authentication_check"].isChecked() : - # If the user entered something, honor that. - # Otherwise, re-use what we suggested. - ac.user = login - ac.password = password - else: - ac.user = None - ac.password = None - - self.__ups_handler = PyNUT.PyNUTClient.from_authconf( ac ) - else: - self.__ups_handler = PyNUT.PyNUTClient( host=host, port=port, login=login, password=password ) - - except : - self.gui_status_message( _("Error connecting to '{0}' ({1})").format( host, sys.exc_info()[1] ) ) - self.gui_status_notification( _("Error connecting to '{0}'\n{1}").format( host, sys.exc_info()[1] ), "warning.png" ) - return - - # Check if selected UPS exists on server... - srv_upses = self.__ups_handler.GetUPSList() - self.__current_ups = self.__widgets["ups_list_combo"].currentText() - - if self.__current_ups.encode('ascii') not in srv_upses : - self.gui_status_message( _("Device '%s' not found on server") % self.__current_ups ) - self.gui_status_notification( _("Device '%s' not found on server") % self.__current_ups, "warning.png" ) - return - - if not self.__ups_handler.CheckUPSAvailable( self.__current_ups ): - self.gui_status_message( _("UPS '{0}' is not reachable").format( self.__current_ups ) ) - self.gui_status_notification( _("UPS '{0}' is not reachable").format( self.__current_ups ), "warning.png" ) - return - - self.__connected = True - self.__widgets["ups_connect"].hide() - self.__widgets["ups_disconnect"].show() - self.__widgets["ups_infos"].show() - self.__widgets["ups_params_box"].setEnabled( False ) - self.__widgets["menu_favorites_root"].setEnabled( False ) - self.__widgets["ups_params_box"].hide() - - commands = self.__ups_handler.GetUPSCommands( self.__current_ups ) - self.__ups_commands = list(commands.keys()) - self.__ups_commands.sort() - - # Refresh UPS commands combo box - self.__widgets["ups_commands_combo_store"].clear() - for desc in self.__ups_commands : - # TODO: Style as "%s
%s" - self.__widgets["ups_commands_combo_store"].addItem( "%s\n%s" % ( desc.decode('ascii'), commands[desc].decode('ascii') ) ) - - self.__widgets["ups_commands_combo"].setCurrentIndex( 0 ) - - # Update UPS vars manually before the thread - self.__ups_vars = self.__ups_handler.GetUPSVars( self.__current_ups ) - self.__ups_rw_vars = self.__ups_handler.GetRWVars( self.__current_ups ) - self.__gui_update_ups_vars_view() - - # Try to resize the main window... - # FIXME: For some reason, calling this immediately doesn't work right - QTimer.singleShot(10, self.__widgets["main_window"].adjustSize) - - # Start the GUI updater thread - self.__gui_thread = gui_updater( self ) - self.__gui_thread.start() - - self.gui_status_message( _("Connected to '{0}' on {1}").format( self.__current_ups, host ) ) - - - #------------------------------------------------------------------- - # Refresh UPS vars in the treeview - def __gui_update_ups_vars_view( self, widget=None ) : - if self.__ups_handler : - vars = self.__ups_vars - rwvars = self.__ups_rw_vars - - self.__widgets["ups_vars_tree_store"].removeRows(0, self.__widgets["ups_vars_tree_store"].rowCount()) - - for k,v in vars.items() : - if ( k in rwvars ) : - icon_file = self.__find_res_file( "pixmaps", "var-rw.png" ) - else : - icon_file = self.__find_res_file( "pixmaps", "var-ro.png" ) - - icon = QIcon( icon_file ) - item_icon = QStandardItem(icon, '') - item_icon.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemNeverHasChildren) - item_var_name = QStandardItem( k.decode('ascii') ) - item_var_name.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemNeverHasChildren) - item_var_val = QStandardItem( v.decode('ascii') ) - item_var_val.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemNeverHasChildren) - self.__widgets["ups_vars_tree_store"].appendRow( (item_icon, item_var_name, item_var_val) ) - self.__widgets["ups_vars_tree"].resizeColumnToContents( 0 ) - self.__widgets["ups_vars_tree"].resizeColumnToContents( 1 ) - - - def gui_init_unconnected( self ) : - self.__connected = False - self.__widgets["ups_connect"].show() - self.__widgets["ups_disconnect"].hide() - self.__widgets["ups_infos"].hide() - self.__widgets["ups_params_box"].setEnabled( True ) - self.__widgets["menu_favorites_root"].setEnabled( True ) - self.__widgets["status_icon"].setToolTip( _("Not connected") ) - self.__widgets["ups_params_box"].show() - - # Try to resize the main window... - self.__widgets["main_window"].adjustSize() - - #------------------------------------------------------------------- - # Disconnect from the UPS - def disconnect_from_ups( self, widget=None ) : - self.gui_init_unconnected() - - # Stop the GUI updater thread - self.__gui_thread.stop_thread() - - del self.__ups_handler - self.gui_status_message( _("Disconnected from '%s'") % self.__current_ups ) - self.change_status_icon( "on_line", blink=False ) - self.__current_ups = None - -#----------------------------------------------------------------------- -# GUI Updater class -# This class updates the main gui with data from connected UPS -class gui_updater : - - __parent_class = None - __stop_thread = False - was_online = True - __reconnect_attempted = False - - def __init__( self, parent_class ) : - threading.Thread.__init__( self ) - self.__parent_class = parent_class - - def start( self ) : - self.__timer = QTimer() - self.__timer.timeout.connect(self.__update) - self.__timer.start(1000) - - def __update( self ) : - - ups = self.__parent_class._interface__current_ups - - # Define a dict containing different UPS status - status_mapper = { b"LB" : ( "%s" % _("Low batteries"), _("Low batteries") ), - b"RB" : ( "%s" % _("Replace batteries !"), _("Replace batteries !") ), - b"ALARM" : ( "%s" % _("Active alarms !"), _("Active alarms !") ), - b"BYPASS" : ( "Bypass %s" % _("(no battery protection)"), "%s %s" % ( _("Bypass"), _("(no battery protection)") ) ), - b"ECO" : ( _("In ECO mode (as defined by vendor)"), _("In ECO mode (as defined by vendor)") ), - b"CAL" : ( _("Performing runtime calibration"), _("Performing runtime calibration") ), - b"OFF" : ( "%s (%s)" % ( _("Offline"), _("not providing power to the load") ), "%s (%s)" % ( _("Offline"), _("not providing power to the load") ) ), - b"OVER" : ( "%s (%s)" % ( _("Overloaded !"), _("there is too much load for device") ), "%s (%s)" % ( _("Overloaded !"), _("there is too much load for device") ) ), - b"TRIM" : ( _("Triming (UPS is triming incoming voltage)"), plain_status_text( _("Triming (UPS is triming incoming voltage)") ) ), - b"BOOST" : ( _("Boost (UPS is boosting incoming voltage)"), plain_status_text( _("Boost (UPS is boosting incoming voltage)") ) ) - } - - if not self.__stop_thread : - try : - vars = self.__parent_class._interface__ups_handler.GetUPSVars( ups ) - self.__parent_class._interface__ups_vars = vars - - # Text displayed on the status frame - text_left = "" - text_right = "" - status_parts = [] - tooltip_status_parts = [] - tooltip_lines = [] - - text_left += "%s
" % _("Device status :") - - if ( vars.get(b"ups.status").find(b"OL") != -1 ) : - status_parts.append( "%s" % _("Online") ) - tooltip_status_parts.append( _("Online") ) - if not self.was_online : - self.__parent_class.change_status_icon( "on_line", blink=False ) - self.was_online = True - - if ( vars.get(b"ups.status").find(b"OB") != -1 ) : - status_parts.append( "%s" % _("On batteries") ) - tooltip_status_parts.append( _("On batteries") ) - if self.was_online : - self.__parent_class.change_status_icon( "on_battery", blink=True ) - self.__parent_class.gui_status_notification( _("Device is running on batteries"), "on_battery.png" ) - self.was_online = False - - # Check for additionnal information - for k,v in status_mapper.items() : - if vars.get(b"ups.status").find(k) != -1 : - status_parts.append( v[0] ) - tooltip_status_parts.append( v[1] ) - - # CHRG and DISCHRG cannot be trated with the previous loop ;) - if ( vars.get(b"ups.status").find(b"DISCHRG") != -1 ) : - status_parts.append( "%s" % _("discharging") ) - tooltip_status_parts.append( _("discharging") ) - elif ( vars.get(b"ups.status").find(b"CHRG") != -1 ) : - status_parts.append( "%s" % _("charging") ) - tooltip_status_parts.append( _("charging") ) - - text_right += " - ".join( status_parts ) - tooltip_lines.append( "%s %s" % ( _("Device status :"), " - ".join( tooltip_status_parts ) ) ) - text_right += "
" - - if ( b"ups.mfr" in vars ) : - text_left += "%s

" % _("Model :") - text_right += "%s
%s
" % ( - vars.get(b"ups.mfr",b"").decode('ascii'), - vars.get(b"ups.model",b"").decode('ascii'), - ) - tooltip_lines.append( "%s %s %s" % ( _("Model :"), tooltip_value( vars.get(b"ups.mfr",b"") ), tooltip_value( vars.get(b"ups.model",b"") ) ) ) - - if ( b"ups.temperature" in vars ) : - text_left += "%s
" % _("Temperature :") - text_right += "%s
" % int( float( vars.get( b"ups.temperature", 0 ) ) ) - tooltip_lines.append( "%s %s" % ( _("Temperature :"), int( float( vars.get( b"ups.temperature", 0 ) ) ) ) ) - - if ( b"battery.voltage" in vars ) : - text_left += "%s
" % _("Battery voltage :") - text_right += "%sv
" % (vars.get( b"battery.voltage", 0 ).decode('ascii'),) - tooltip_lines.append( "%s %s" % ( _("Battery voltage :"), tooltip_value( vars.get( b"battery.voltage", 0 ), "v" ) ) ) - - self.__parent_class._interface__widgets["ups_status_left"].setText( text_left[:-4] ) - self.__parent_class._interface__widgets["ups_status_right"].setText( text_right[:-4] ) - - # UPS load and battery charge progress bars - self.__parent_class._interface__widgets["progress_battery_charge"].setRange( 0, 100 ) - if ( b"battery.charge" in vars ) : - charge = vars.get( b"battery.charge", "0" ) - self.__parent_class._interface__widgets["progress_battery_charge"].setValue( int( float( charge ) ) ) - self.__parent_class._interface__widgets["progress_battery_charge"].resetFormat() - tooltip_lines.append( "%s %s%%" % ( _("Battery charge :"), int( float( charge ) ) ) ) - else : - self.__parent_class._interface__widgets["progress_battery_charge"].setValue( 0 ) - self.__parent_class._interface__widgets["progress_battery_charge"].setFormat( _("Not available") ) - # FIXME: Some themes don't draw text, so swap it with a QLabel? - - self.__parent_class._interface__widgets["progress_battery_load"].setRange( 0, 100 ) - if ( b"ups.load" in vars ) : - load = vars.get( b"ups.load", "0" ) - self.__parent_class._interface__widgets["progress_battery_load"].setValue( int( float( load ) ) ) - self.__parent_class._interface__widgets["progress_battery_load"].resetFormat() - tooltip_lines.append( "%s %s%%" % ( _("UPS load :"), int( float( load ) ) ) ) - else : - self.__parent_class._interface__widgets["progress_battery_load"].setValue( 0 ) - self.__parent_class._interface__widgets["progress_battery_load"].setFormat( _("Not available") ) - # FIXME: Some themes don't draw text, so swap it with a QLabel? - - if ( b"battery.runtime" in vars ) : - autonomy = int( float( vars.get( b"battery.runtime", 0 ) ) ) - - if ( autonomy >= 3600 ) : - info = time.strftime( _("%H hours %M minutes %S seconds"), time.gmtime( autonomy ) ) - elif ( autonomy > 300 ) : - info = time.strftime( _("%M minutes %S seconds"), time.gmtime( autonomy ) ) - else : - info = time.strftime( _("%M minutes %S seconds"), time.gmtime( autonomy ) ) - else : - info = _("Not available") - - self.__parent_class._interface__widgets["ups_status_time"].setText( info ) - tooltip_lines.append( "%s %s" % ( _("Autonomy :"), plain_status_text( info ) ) ) - - # Display UPS status as tooltip for tray icon - self.__parent_class._interface__widgets["status_icon"].setToolTip( "\n".join( tooltip_lines ) ) - - except (socket.error, ConnectionError, EOFError, OSError, PyNUT.PyNUTError) as e: - # Connection error detected - attempt automatic reconnection - if not self.__reconnect_attempted and self.__parent_class._interface__connected: - self.__reconnect_attempted = True - self.__parent_class.gui_status_message( _("Connection lost - attempting to reconnect...") ) - self.__parent_class.gui_status_notification( _("Connection lost"), "warning.png" ) - - # Stop the current timer - self.__timer.stop() - - # Close the socket to avoid "Broken pipe" in subsequent calls if any - if self.__parent_class._interface__ups_handler: - self.__parent_class._interface__ups_handler.disconnect() - - # Schedule reconnection attempt after 2 seconds - QTimer.singleShot(2000, self.__attempt_reconnect) - elif self.__reconnect_attempted: - # Reconnection already in progress, just ignore this update - pass - else: - self.__parent_class.gui_status_message( _("Error from '{0}' ({1})").format( ups, sys.exc_info()[1] ) ) - self.__parent_class.gui_status_notification( _("Error from '{0}'\n{1}").format( ups, sys.exc_info()[1] ), "warning.png" ) - - def __attempt_reconnect( self ) : - """Attempt to reconnect to the UPS after a connection loss.""" - if self.__stop_thread: - return - - try: - # Get connection parameters from the parent class - host = self.__parent_class._interface__widgets["ups_host_entry"].text() - port = int(self.__parent_class._interface__widgets["ups_port_entry"].value()) - login = None - password = None - - if self.__parent_class._interface__widgets["ups_authentication_check"].isChecked(): - login = self.__parent_class._interface__widgets["ups_authentication_login"].text() - password = self.__parent_class._interface__widgets["ups_authentication_password"].text() - - # Teardown old connection handler properly - old_handler = self.__parent_class._interface__ups_handler - if old_handler is not None: - old_handler.disconnect() - del old_handler - - # Create a new connection handler - ac = PyNUT.AuthConf.getAuthConf(host=host, port=port, user=login) - if ac: - ac = ac.clone() - if self.__widgets["ups_authentication_check"].isChecked() : - # If the user entered something, honor that. - # Otherwise, re-use what we suggested. - ac.user = login - ac.password = password - else: - ac.user = None - ac.password = None - - ups_handler = PyNUT.PyNUTClient.from_authconf(ac) - else: - ups_handler = PyNUT.PyNUTClient(host=host, port=port, login=login, password=password) - - # Verify the UPS is available - ups = self.__parent_class._interface__current_ups - srv_upses = ups_handler.GetUPSList() - - if ups.encode('ascii') not in srv_upses: - self.__parent_class.gui_status_message( _("UPS '{0}' not found after reconnection").format(ups) ) - self.__reconnect_attempted = False - # Restart the timer to continue polling - self.__timer.start(1000) - return - - if not ups_handler.CheckUPSAvailable(ups): - self.__parent_class.gui_status_message( _("UPS '{0}' is still not reachable").format(ups) ) - self.__reconnect_attempted = False - self.__timer.start(1000) - return - - # Connection successful - update the parent class - self.__parent_class._interface__ups_handler = ups_handler - self.__parent_class._interface__connected = True - self.__reconnect_attempted = False - - # Refresh UPS data - commands = ups_handler.GetUPSCommands(ups) - self.__parent_class._interface__ups_commands = list(commands.keys()) - self.__parent_class._interface__ups_commands.sort() - - self.__parent_class._interface__ups_vars = ups_handler.GetUPSVars(ups) - self.__parent_class._interface__ups_rw_vars = ups_handler.GetRWVars(ups) - - # Update the GUI - self.__parent_class._interface__gui_update_ups_vars_view() - self.__parent_class.change_status_icon("on_line", blink=False) - self.__parent_class.gui_status_message( _("Reconnected to '{0}' on {1}").format(ups, host) ) - self.__parent_class.gui_status_notification( _("Reconnected to UPS"), "on_line.png" ) - - except (socket.error, ConnectionError, EOFError, OSError, PyNUT.PyNUTError) as e: - self.__parent_class.gui_status_message( _("Reconnection failed: {0}").format(str(e)) ) - self.__reconnect_attempted = False - - # Restart the timer to continue polling - self.__timer.start(1000) - - def stop_thread( self ) : - self.__stop_thread = True - if hasattr(self, '_gui_updater__timer'): - self.__timer.stop() - - -#----------------------------------------------------------------------- -# The main program starts here :-) -if __name__ == "__main__" : - - # Init the localisation - APP = "NUT-Monitor" - DIR = "locale" - - gettext.bindtextdomain( APP, DIR ) - gettext.textdomain( APP ) - _ = gettext.gettext - - for module in ( gettext, ) : - module.bindtextdomain( APP, DIR ) - module.textdomain( APP ) - - nut_debug_level = os.getenv("NUT_DEBUG_LEVEL", 0) - try: - nut_debug_level = int(nut_debug_level) - except ValueError: - nut_debug_level = 0 - except TypeError: - nut_debug_level = 0 - - if nut_debug_level > 0: - PyNUT.debug = True - if nut_debug_level > 5: - PyNUT.AuthConf.setDebug(True) - - gui = interface(sys.argv) - gui.exec() diff --git a/scripts/python/app/NUT-Monitor-test.sh b/scripts/python/app/NUT-Monitor-test.sh new file mode 100755 index 0000000000..70d027e82c --- /dev/null +++ b/scripts/python/app/NUT-Monitor-test.sh @@ -0,0 +1,79 @@ +#!/bin/sh + +set -eu + +test_tmp="`mktemp -d "${TMPDIR-/tmp}/nut-monitor-test.XXXXXX"`" +trap 'rm -rf "$test_tmp"' 0 1 2 3 15 + +cp "${srcdir-.}/app/NUT-Monitor" "$test_tmp/NUT-Monitor" +chmod +x "$test_tmp/NUT-Monitor" + +mkdir "$test_tmp/bin" +cat > "$test_tmp/bin/python-test" <<'EOF' +#!/bin/sh + +if test "${1-}" = -c; then + case "${2-}" in + *PyQt5*) test "${HAVE_QT5-no}" = yes ;; + *PyQt6*) test "${HAVE_QT6-no}" = yes ;; + *gtk*) test "${HAVE_GTK-no}" = yes ;; + *) exit 0 ;; + esac + exit +fi + +basename "$1" +EOF +chmod +x "$test_tmp/bin/python-test" +ln -s python-test "$test_tmp/bin/python" +ln -s python-test "$test_tmp/bin/python2" +ln -s python-test "$test_tmp/bin/python3" + +for variant in py2gtk2 py3qt5 py3qt6; do + printf '#!python3\n' > "$test_tmp/NUT-Monitor-$variant" + chmod +x "$test_tmp/NUT-Monitor-$variant" +done + +run_ok() { + expected="$1" + have_qt5="$2" + have_qt6="$3" + requested="$4" + prefer_py2="${5-false}" + have_gtk="${6-no}" + + output="`env PATH="$test_tmp/bin:/usr/bin:/bin" \ + PYTHON2=python2 PYTHON3=python3 PREFER_PY2="$prefer_py2" \ + HAVE_GTK="$have_gtk" HAVE_QT5="$have_qt5" HAVE_QT6="$have_qt6" \ + NUT_MONITOR_QT="$requested" \ + "$test_tmp/NUT-Monitor" 2>&1`" + echo "$output" | grep "$expected" >/dev/null +} + +run_fail() { + expected="$1" + have_qt5="$2" + have_qt6="$3" + requested="$4" + + set +e + output="`env PATH="$test_tmp/bin:/usr/bin:/bin" \ + PYTHON2=python2 PYTHON3=python3 PREFER_PY2=false \ + HAVE_QT5="$have_qt5" HAVE_QT6="$have_qt6" \ + NUT_MONITOR_QT="$requested" \ + "$test_tmp/NUT-Monitor" 2>&1`" + result=$? + set -e + test "$result" -ne 0 + echo "$output" | grep "$expected" >/dev/null +} + +run_ok 'NUT-Monitor-py3qt6' yes yes auto +run_ok 'NUT-Monitor-py3qt5' yes no auto +run_ok 'NUT-Monitor-py3qt6' no yes auto +run_ok 'NUT-Monitor-py3qt5' yes yes 5 +run_ok 'NUT-Monitor-py3qt6' yes yes 6 +run_ok 'NUT-Monitor-py2gtk2' yes yes auto true yes +run_ok 'NUT-Monitor-py3qt5' yes yes 5 true yes +run_fail 'No usable Python interpreter' no no auto +run_fail 'Unsupported value of NUT_MONITOR_QT' yes yes invalid diff --git a/scripts/python/app/README.adoc b/scripts/python/app/README.adoc index 3aaaae1112..9b623c7256 100644 --- a/scripts/python/app/README.adoc +++ b/scripts/python/app/README.adoc @@ -30,14 +30,12 @@ For quick tests (e.g. during development), you can run the clients like this: or: ---- -:; PYTHONPATH=../module/ python3 ./NUT-Monitor-py3qt5.in +:; PYTHONPATH=../module/ NUT_MONITOR_QT=5 python3 ./NUT-Monitor-py3qt.in ---- -or: - ----- -:; PYTHONPATH=../module/ python3 ./NUT-Monitor-py3qt6.in ----- +Use `NUT_MONITOR_QT=6` to exercise the PyQt6 backend. The generated +`NUT-Monitor-py3qt5` and `NUT-Monitor-py3qt6` commands select their named +backend directly. Localization ------------ @@ -65,7 +63,9 @@ This component ships implementation-specific `nut-monitor-py2gtk2.desktop`, `nut-monitor-py3qt5.desktop` and `nut-monitor-py3qt6.desktop` files which allows a user to have icons for either variants separately, as well as the legacy-named `nut-monitor.desktop` for running the wrapper script `NUT-Monitor` which picks -an implementation best suited for the current run-time circumstances. +an implementation best suited for the current run-time circumstances. Set +`NUT_MONITOR_QT=5` or `NUT_MONITOR_QT=6` to require a particular Python 3 +backend through the wrapper. Screenshots ----------- @@ -101,7 +101,7 @@ export PYTHONIOENCODING ---- To iterate during specifically localization development, you can edit the `.in` -template sources of the scripts and the localization files, and (from the `app` +template sources and the localization files, and (from the `app` directory) run with: ---- @@ -128,7 +128,8 @@ by David Goncalves NUT-Monitor was converted to Python 3 + Qt5 by Luke Dashjr -PyNUT was extended, and two variants of NUT-Monitor converged and wrapped +PyNUT was extended, and two generations of NUT-Monitor converged and wrapped for Python 2+3 dual support by Jim Klimov -NUT-Monitor was ported to Python 3 + Qt6 by Sebastian Kuttnig +NUT-Monitor was ported to Python 3 + Qt6 by Sebastian Kuttnig. The Qt5 and Qt6 +backends now share one Python 3 implementation.