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.