Source code for qtpyvcp.widgets.button_widgets.vcpvar_button
import os
from PySide6.QtCore import Property, QTimer, Qt
from PySide6.QtWidgets import QPushButton
from qtpyvcp.widgets import VCPWidget
from qtpyvcp.widgets.base_widgets.var_widget_mixin import VarWidgetMixin
from qtpyvcp.utilities import logger
from qtpyvcp.utilities.qt_safety import safe_qt_callback
from qtpyvcp.plugins import getPlugin
from qtpyvcp.actions.machine_actions import issue_mdi
LOG = logger.getLogger(__name__)
IN_DESIGNER = os.getenv('DESIGNER') != None
def _safe_import_linuxcnc():
"""Import linuxcnc with intelligent error handling"""
if IN_DESIGNER:
# Expected in designer mode - don't log
return None
try:
import linuxcnc
return linuxcnc
except ImportError as e:
# Unexpected in runtime mode - log the error
LOG.error(f"Failed to import linuxcnc in runtime mode: {e}")
return None
[docs]
class VCPVarPushButton(QPushButton, VCPWidget, VarWidgetMixin):
"""Var Parameter Push Button
A checkable button that keeps its state in a LinuxCNC numbered
parameter, such as ``#3014``, so the setting is stored in the var file.
Toggling the button writes ``#<varParameterNumber> = 1`` when checked or
``= 0`` when unchecked, as an MDI command, ``writeDelay`` ms later. At
startup, and whenever the var file changes, the button is set from the
parameter: any non-zero value checks it.
Setup:
* Set ``varParameterNumber`` in Qt Designer. Nothing is read or written
while it is 0.
* The var file is found automatically from ``[RS274NGC] PARAMETER_FILE``
in the INI, through the ``var_file_manager`` plugin, which is loaded by
default.
* ``outputAsInt`` makes ``value()`` return 0 or 1 instead of False or
True. The parameter is written as 0 or 1 either way.
Safety:
* With ``requireHomed`` on, the default, the button is disabled unless
the machine is on, homed and idle. This is checked at startup and
whenever homing changes, and a write is refused unless the machine is
in that state.
Widget rules can set ``Text``, ``Checked`` and ``Value`` as well as the
standard properties.
"""
DEFAULT_RULE_PROPERTY = 'Enable'
RULE_PROPERTIES = VCPWidget.RULE_PROPERTIES.copy()
RULE_PROPERTIES.update({
'Text': ['setText', str],
'Checked': ['setChecked', bool],
'Value': ['setValue', [bool, int]],
})
def __init__(self, parent=None):
super(VCPVarPushButton, self).__init__(parent)
# Initialize VarWidgetMixin
VarWidgetMixin.__init__(self)
# Make button checkable by default for toggle behavior
self.setCheckable(True)
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
# Widget properties for var parameter functionality
self._auto_write_enabled = True
self._write_delay = 500 # ms delay before writing to prevent excessive writes
self._output_as_int = False # Default to boolean output
self._require_homed = True # Safety feature: require machine to be homed
# Status monitoring for safety
self._status = None
self._status_homed_callback = None
self._original_enabled_state = True
# Internal state tracking
self._internal_value = False
self._user_just_toggled = False
# Timer for delayed writing
self._write_timer = QTimer()
self._write_timer.setSingleShot(True)
self._write_timer.timeout.connect(self._writeToLinuxCNC)
# Connect to toggle changes
self.toggled.connect(self._onToggled)
[docs]
def getParameterFilePath(self):
"""Get the automatically detected parameter file path"""
if not self._var_manager:
return None
return getattr(self._var_manager, '_parameter_file_path', None)
[docs]
def getConfigurationInfo(self):
"""
Get debugging information about the current configuration.
Returns:
dict: Configuration information including paths and settings
"""
var_file_path = self.getParameterFilePath()
return {
'var_file_path': var_file_path,
'var_parameter_number': self.var_parameter_number,
'auto_write_enabled': self._auto_write_enabled,
'write_delay': self._write_delay,
'output_as_int': self._output_as_int,
'require_homed': self._require_homed,
'var_monitoring_enabled': self.var_monitoring_enabled,
'parameter_file_exists': os.path.exists(var_file_path) if var_file_path else False
}
@Property(int)
def varParameterNumber(self):
"""LinuxCNC parameter number to write to (e.g., 3014 for #3014)"""
return self.var_parameter_number
@varParameterNumber.setter
def varParameterNumber(self, param_num):
self.var_parameter_number = int(param_num)
@Property(bool)
def autoWriteEnabled(self):
"""Whether to automatically write changes to LinuxCNC parameters"""
return self._auto_write_enabled
@autoWriteEnabled.setter
def autoWriteEnabled(self, enabled):
self._auto_write_enabled = bool(enabled)
@Property(int)
def writeDelay(self):
"""Delay in milliseconds before writing to LinuxCNC after button toggle"""
return self._write_delay
@writeDelay.setter
def writeDelay(self, delay):
self._write_delay = int(delay)
@Property(bool)
def outputAsInt(self):
"""If True, value() returns 0/1 integers. If False (default), returns True/False booleans."""
return self._output_as_int
@outputAsInt.setter
def outputAsInt(self, use_int):
self._output_as_int = bool(use_int)
@Property(bool)
def requireHomed(self):
"""Whether to require machine to be homed before enabling the widget"""
return self._require_homed
@requireHomed.setter
def requireHomed(self, require):
self._require_homed = bool(require)
# Re-evaluate enabled state when this property changes
self._updateEnabledState()
def _connectStatusPlugin(self):
"""Connect to the status plugin for monitoring machine state"""
self._status = getPlugin('status')
if not self._status:
LOG.warning("VCPVarPushButton: Status plugin not available")
return
# Connect to all homed status signal using a tracked safe callback so
# terminate() can disconnect the exact callable that was connected.
self._status_homed_callback = safe_qt_callback(self, self._updateEnabledState)
self._status.all_axes_homed.signal.connect(self._status_homed_callback)
LOG.debug("VCPVarPushButton: Connected to status plugin for homing monitoring")
# Update initial state
self._updateEnabledState()
def _updateEnabledState(self):
"""Update widget enabled state based on machine state"""
if not self._require_homed:
# If homing not required, use original enabled state
super().setEnabled(self._original_enabled_state)
return
if self._status is None:
# If no status plugin, default to disabled for safety
super().setEnabled(False)
self.setToolTip("Status plugin not available - widget disabled for safety")
return
# Check if machine is in safe state for parameter editing
linuxcnc = _safe_import_linuxcnc()
if linuxcnc is None:
# Disable widget when linuxcnc unavailable
super().setEnabled(False)
self.setToolTip("LinuxCNC not available - widget disabled")
return
stat = linuxcnc.stat()
stat.poll()
# Check machine state: ON, HOMED, and IDLE (same as MDI button safety)
is_machine_on = stat.task_state == linuxcnc.STATE_ON
is_all_homed = self._status.allHomed()
is_idle = stat.interp_state == linuxcnc.INTERP_IDLE
is_safe = is_machine_on and is_all_homed and is_idle
if is_safe:
super().setEnabled(self._original_enabled_state)
self.setToolTip("") # Clear any safety tooltip
else:
super().setEnabled(False)
if not is_machine_on:
self.setToolTip("Widget disabled: Machine must be ON")
elif not is_all_homed:
self.setToolTip("Widget disabled: Machine must be HOMED")
elif not is_idle:
self.setToolTip("Widget disabled: Machine must be IDLE")
[docs]
def setEnabled(self, enabled):
"""Override setEnabled to track original state and respect safety requirements"""
self._original_enabled_state = enabled
self._updateEnabledState()
[docs]
def value(self):
"""Return the current checked state as boolean or integer based on outputAsInt property"""
checked_state = self.isChecked()
if self._output_as_int:
return 1 if checked_state else 0
else:
return checked_state
[docs]
def setValue(self, value):
"""Set the checked state from a boolean, integer, or compatible value"""
if isinstance(value, bool):
self.setChecked(value)
elif isinstance(value, (int, float)):
self.setChecked(bool(value))
elif isinstance(value, str):
# Handle string representations of boolean values
value_lower = value.lower().strip()
if value_lower in ('true', '1', 'yes', 'on'):
self.setChecked(True)
elif value_lower in ('false', '0', 'no', 'off'):
self.setChecked(False)
else:
# For numeric strings, convert to float then to bool
self.setChecked(bool(float(value)))
else:
self.setChecked(bool(value))
[docs]
def text(self):
"""Override text() to return empty string so parameter collection uses value() instead"""
return "" # Return empty string so get_val() falls through to value() method
[docs]
def setDisplayChecked(self, checked):
"""Set checked state without triggering signals (for loading from var file)"""
self.blockSignals(True)
self.setChecked(checked)
self.blockSignals(False)
def _onToggled(self, checked):
"""Handle button toggle and schedule LinuxCNC parameter update if auto-write is enabled"""
LOG.debug(f"VCPVarPushButton: Button toggled to {checked}")
# Update internal value when user toggles
self._internal_value = checked
self._user_just_toggled = True
if self._auto_write_enabled and self.var_parameter_number > 0:
# Reset the timer to delay the write
self._write_timer.stop()
self._write_timer.start(self._write_delay)
# Reset the flag after a short delay
QTimer.singleShot(100, lambda: setattr(self, '_user_just_toggled', False))
[docs]
def writeToLinuxCNC(self, force=False):
"""
Public method to manually trigger writing to LinuxCNC parameters.
Args:
force (bool): If True, write immediately without delay
"""
if force:
self._writeToLinuxCNC()
else:
self._onToggled(self.isChecked())
def _writeToLinuxCNC(self):
"""Write the current button state to LinuxCNC via MDI command"""
if IN_DESIGNER:
LOG.debug("VCPVarPushButton: skipping write in Designer mode")
return
if not self.var_parameter_number > 0:
LOG.warning("VCPVarPushButton: parameter number not set")
return
# Safety check: ensure machine is in safe state if required
if self._require_homed and self._status:
linuxcnc = _safe_import_linuxcnc()
if linuxcnc is None:
return # Skip writing when linuxcnc unavailable
stat = linuxcnc.stat()
stat.poll()
# Check machine state: ON, HOMED, and IDLE
is_machine_on = stat.task_state == linuxcnc.STATE_ON
is_all_homed = self._status.allHomed()
is_idle = stat.interp_state == linuxcnc.INTERP_IDLE
if not (is_machine_on and is_all_homed and is_idle):
LOG.warning("VCPVarPushButton: Cannot write parameter - machine not in safe state")
return
# Get the button state as 0 or 1
if self._internal_value is not None:
value = 1 if self._internal_value else 0
LOG.debug(f"VCPVarPushButton: Using internal value: {value}")
else:
value = 1 if self.isChecked() else 0
LOG.debug(f"VCPVarPushButton: Using button checked state: {value}")
# Use LinuxCNC MDI command to set parameter
mdi_command = f"#{self.var_parameter_number} = {value}"
LOG.debug(f"VCPVarPushButton: Issuing MDI command: {mdi_command}")
issue_mdi(mdi_command)
LOG.debug(f"VCPVarPushButton: Set parameter #{self.var_parameter_number} = {value} via MDI")
# VarWidgetMixin abstract method implementations
def _load_parameter_value(self, value):
"""Load parameter value from var file manager into the widget"""
if value is not None:
# Temporarily disable auto-write to prevent feedback loop
old_auto_write = self._auto_write_enabled
self._auto_write_enabled = False
# Convert numeric value to boolean (non-zero = True)
self.setDisplayChecked(bool(value))
self._auto_write_enabled = old_auto_write
LOG.debug(f"VCPVarPushButton: Loaded parameter #{self.var_parameter_number} = {bool(value)}")
else:
LOG.debug(f"VCPVarPushButton: Could not load parameter #{self.var_parameter_number}")
def _get_widget_value(self):
"""Get the current widget value for comparison"""
return 1.0 if self.isChecked() else 0.0
[docs]
def initialize(self):
"""Initialize the widget - called by VCP system"""
# Initialize VarWidgetMixin (sets up var file monitoring)
self._setup_var_monitoring()
# Connect to status plugin for safety monitoring
self._connectStatusPlugin()
LOG.debug(f"VCPVarPushButton initialized: param #{self.var_parameter_number}, auto_write={self._auto_write_enabled}, require_homed={self._require_homed}")
[docs]
def terminate(self):
"""Cleanup when widget is destroyed"""
# Cleanup VarWidgetMixin (disconnects from var file monitoring)
self._cleanup_var_monitoring()
# Disconnect from status plugin
if self._status and self._status_homed_callback is not None:
try:
self._status.all_axes_homed.signal.disconnect(self._status_homed_callback)
except (RuntimeError, TypeError):
# Signal may already be disconnected during shutdown ordering.
pass
self._status_homed_callback = None
# Restore original enabled state
if hasattr(self, '_original_enabled_state'):
self.setEnabled(self._original_enabled_state)
if self._write_timer.isActive():
self._write_timer.stop()
LOG.debug("VCPVarPushButton terminated")