diff options
author | ghostop14 <ghostop14@gmail.com> | 2020-02-02 13:52:39 -0500 |
---|---|---|
committer | devnulling <devnulling@users.noreply.github.com> | 2020-02-19 15:46:54 -0800 |
commit | 5cd7b4cd472e9dca41f19e2cdfed4393374c9fe0 (patch) | |
tree | cb6a607483d03510b90d43c5956e88b691744fff /gr-qtgui/python/qtgui | |
parent | bebed18070bb3366c53f8d1b775c5ed5959859ea (diff) |
gr-qtgui: Incorporate new GUI controls
These updates expand the user interface capabilities of
GNU Radio. This PR includes all of the controls more fully
documented here: https://github.com/ghostop14/gr-guiextra
Diffstat (limited to 'gr-qtgui/python/qtgui')
-rw-r--r-- | gr-qtgui/python/qtgui/CMakeLists.txt | 19 | ||||
-rw-r--r-- | gr-qtgui/python/qtgui/__init__.py | 21 | ||||
-rw-r--r-- | gr-qtgui/python/qtgui/auto_correlator_sink.py | 132 | ||||
-rw-r--r-- | gr-qtgui/python/qtgui/azelplot.py | 128 | ||||
-rw-r--r-- | gr-qtgui/python/qtgui/compass.py | 290 | ||||
-rw-r--r-- | gr-qtgui/python/qtgui/dialcontrol.py | 142 | ||||
-rw-r--r-- | gr-qtgui/python/qtgui/dialgauge.py | 208 | ||||
-rw-r--r-- | gr-qtgui/python/qtgui/digitalnumbercontrol.py | 346 | ||||
-rw-r--r-- | gr-qtgui/python/qtgui/distanceradar.py | 119 | ||||
-rw-r--r-- | gr-qtgui/python/qtgui/graphicitem.py | 194 | ||||
-rw-r--r-- | gr-qtgui/python/qtgui/graphicoverlay.py | 103 | ||||
-rw-r--r-- | gr-qtgui/python/qtgui/ledindicator.py | 197 | ||||
-rw-r--r-- | gr-qtgui/python/qtgui/levelgauge.py | 214 | ||||
-rw-r--r-- | gr-qtgui/python/qtgui/msgcheckbox.py | 119 | ||||
-rw-r--r-- | gr-qtgui/python/qtgui/msgpushbutton.py | 56 | ||||
-rw-r--r-- | gr-qtgui/python/qtgui/qa_qtgui.py | 2 | ||||
-rw-r--r-- | gr-qtgui/python/qtgui/togglebutton.py | 122 | ||||
-rw-r--r-- | gr-qtgui/python/qtgui/toggleswitch.py | 210 |
18 files changed, 2618 insertions, 4 deletions
diff --git a/gr-qtgui/python/qtgui/CMakeLists.txt b/gr-qtgui/python/qtgui/CMakeLists.txt index 2041038c84..bdac9ede9c 100644 --- a/gr-qtgui/python/qtgui/CMakeLists.txt +++ b/gr-qtgui/python/qtgui/CMakeLists.txt @@ -15,8 +15,23 @@ configure_file(util.py.cmakein "${CMAKE_CURRENT_BINARY_DIR}/util.py" @ONLY) GR_PYTHON_INSTALL( FILES __init__.py - "${CMAKE_CURRENT_BINARY_DIR}/range.py" - "${CMAKE_CURRENT_BINARY_DIR}/util.py" + compass.py + togglebutton.py + msgpushbutton.py + msgcheckbox.py + distanceradar.py + azelplot.py + digitalnumbercontrol.py + dialcontrol.py + ledindicator.py + graphicitem.py + levelgauge.py + dialgauge.py + toggleswitch.py + graphicoverlay.py + auto_correlator_sink.py + "${CMAKE_CURRENT_BINARY_DIR}/range.py" + "${CMAKE_CURRENT_BINARY_DIR}/util.py" DESTINATION ${GR_PYTHON_DIR}/gnuradio/qtgui ) diff --git a/gr-qtgui/python/qtgui/__init__.py b/gr-qtgui/python/qtgui/__init__.py index b56e2f78bd..d727748130 100644 --- a/gr-qtgui/python/qtgui/__init__.py +++ b/gr-qtgui/python/qtgui/__init__.py @@ -1,5 +1,5 @@ # -# Copyright 2011 Free Software Foundation, Inc. +# Copyright 2011, 2020 Free Software Foundation, Inc. # # This file is part of GNU Radio # @@ -25,3 +25,22 @@ except ImportError: from .range import Range, RangeWidget from . import util + +from .compass import GrCompass +from .togglebutton import ToggleButton +from .msgpushbutton import MsgPushButton +from .distanceradar import DistanceRadar +from .azelplot import AzElPlot +from .msgcheckbox import MsgCheckBox +from .digitalnumbercontrol import MsgDigitalNumberControl +from .dialcontrol import GrDialControl +from .ledindicator import GrLEDIndicator +from .graphicitem import GrGraphicItem +from .levelgauge import GrLevelGauge +from .dialgauge import GrDialGauge +from .toggleswitch import GrToggleSwitch +from .graphicoverlay import GrGraphicOverlay +from .auto_correlator_sink import AutoCorrelatorSink +from .auto_correlator_sink import AutoCorrelator +from .auto_correlator_sink import Normalize + diff --git a/gr-qtgui/python/qtgui/auto_correlator_sink.py b/gr-qtgui/python/qtgui/auto_correlator_sink.py new file mode 100644 index 0000000000..877e60d0ad --- /dev/null +++ b/gr-qtgui/python/qtgui/auto_correlator_sink.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2020 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# + +import math +import sip + +from gnuradio import gr +from gnuradio import qtgui +from gnuradio import blocks, fft, filter + +from PyQt5 import QtGui +from PyQt5.QtWidgets import QWidget + +class Normalize(gr.hier_block2): + def __init__(self, vecsize=1024): + gr.hier_block2.__init__( + self, "Normalize", + gr.io_signature(1, 1, gr.sizeof_float*vecsize), + gr.io_signature(1, 1, gr.sizeof_float*vecsize), + ) + + ################################################## + # Parameters + ################################################## + self.vecsize = vecsize + + ################################################## + # Blocks + ################################################## + self.blocks_stream_to_vector_0 = blocks.stream_to_vector(gr.sizeof_float, vecsize) + self.blocks_repeat_0 = blocks.repeat(gr.sizeof_float, vecsize) + self.blocks_max_xx_0 = blocks.max_ff(vecsize) + self.blocks_divide_xx_0 = blocks.divide_ff(vecsize) + + ################################################## + # Connections + ################################################## + self.connect((self.blocks_divide_xx_0, 0), (self, 0)) + self.connect((self.blocks_stream_to_vector_0, 0), (self.blocks_divide_xx_0, 1)) + self.connect((self, 0), (self.blocks_max_xx_0, 0)) + self.connect((self.blocks_repeat_0, 0), (self.blocks_stream_to_vector_0, 0)) + self.connect((self.blocks_max_xx_0, 0), (self.blocks_repeat_0, 0)) + self.connect((self, 0), (self.blocks_divide_xx_0, 0)) + + + def get_vecsize(self): + return self.vecsize + + def set_vecsize(self, vecsize): + self.vecsize = vecsize + +class AutoCorrelator(gr.hier_block2): + """ + This block uses the Wiener Khinchin theorem that the FFT of a signal's + power spectrum is its auto-correlation function. + FAC Size controls the FFT size and therefore the length of time + (samp_rate/fac_size) the auto-correlation runs over. + """ + def __init__(self, sample_rate, fac_size, fac_decimation, use_db): + gr.hier_block2.__init__(self,"AutoCorrelator", + gr.io_signature(1, 1, gr.sizeof_gr_complex), # Input sig + gr.io_signature(1, 1, gr.sizeof_float*fac_size)) # Output sig + + self.fac_size = fac_size + self.fac_decimation = fac_decimation + self.sample_rate = sample_rate + + streamToVec = blocks.stream_to_vector(gr.sizeof_gr_complex, self.fac_size) + # Make sure N is at least 1 + decimation = int(self.sample_rate/self.fac_size/self.fac_decimation) + self.one_in_n = blocks.keep_one_in_n(gr.sizeof_gr_complex * self.fac_size, max(1, decimation)) + + # FFT Note: No windowing. + fac = fft.fft_vcc(self.fac_size, True, ()) + + complex2Mag = blocks.complex_to_mag(self.fac_size) + self.avg = filter.single_pole_iir_filter_ff_make(1.0, self.fac_size) + + fac_fac = fft.fft_vfc(self.fac_size, True, ()) + fac_c2mag = blocks.complex_to_mag_make(fac_size) + + # There's a note in Baz's block about needing to add 3 dB to each bin but the DC bin, however it was never implemented + n = 20 + k = -20 * math.log10(self.fac_size) + log = blocks.nlog10_ff_make(n, self.fac_size, k) + + if use_db: + self.connect(self, streamToVec, self.one_in_n, fac, complex2Mag, fac_fac, fac_c2mag, self.avg, log, self) + else: + self.connect(self, streamToVec, self.one_in_n, fac, complex2Mag, fac_fac, fac_c2mag, self.avg, self) + +class AutoCorrelatorSink(gr.hier_block2): + """ + docstring for block AutoCorrelatorSink + """ + def __init__(self, sample_rate, fac_size, fac_decimation, title, autoScale, grid, yMin, yMax, use_db): + gr.hier_block2.__init__(self, + "AutoCorrelatorSink", + gr.io_signature(1, 1, gr.sizeof_gr_complex), # Input signature + gr.io_signature(0, 0, 0)) # Output signature + + self.fac_size = fac_size + self.fac_decimation = fac_decimation + self.sample_rate = sample_rate + + autoCorr = AutoCorrelator(sample_rate, fac_size, fac_decimation, use_db) + vecToStream = blocks.vector_to_stream(gr.sizeof_float, self.fac_size) + + self.timeSink = qtgui.time_sink_f(self.fac_size/2, sample_rate, title, 1) + self.timeSink.enable_grid(grid) + self.timeSink.set_y_axis(yMin, yMax) + self.timeSink.enable_autoscale(autoScale) + self.timeSink.disable_legend() + self.timeSink.set_update_time(0.1) + + if use_db: + self.connect(self, autoCorr, vecToStream, self.timeSink) + else: + norm = Normalize(self.fac_size) + self.connect(self, autoCorr, norm, vecToStream, self.timeSink) + + + def getWidget(self): + return sip.wrapinstance(self.timeSink.pyqwidget(), QWidget) diff --git a/gr-qtgui/python/qtgui/azelplot.py b/gr-qtgui/python/qtgui/azelplot.py new file mode 100644 index 0000000000..d23d39234d --- /dev/null +++ b/gr-qtgui/python/qtgui/azelplot.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2020 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# + +from PyQt5 import QtWidgets +import numpy as np +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure +import math + +from gnuradio import gr +import pmt + +class AzElPlot(gr.sync_block, FigureCanvas): + """ + This block creates a polar plot with azimuth represented as the angle + clockwise around the circle, and elevation represented as the radius. + 90 degrees elevation is center (directly overhead), + while the horizon (0 degrees) is the outside circe. Note that if an + elevation < 0 is provided, the marker will turn to an open circle + on the perimeter at the specified azimuth angle. + """ + def __init__(self, lbl, backgroundColor, dotColor, Parent=None, + width=4, height=4, dpi=90): + gr.sync_block.__init__(self, name = "MsgPushButton", in_sig = None, + out_sig = None) + + self.lbl = lbl + + self.message_port_register_in(pmt.intern("azel")) + self.set_msg_handler(pmt.intern("azel"), self.msgHandler) + + self.dotColor = dotColor + self.backgroundColor = backgroundColor + self.scaleColor = 'black' + if (self.backgroundColor == 'black'): + self.scaleColor = 'white' + + self.fig = Figure(figsize=(width, height), dpi=dpi) + self.fig.patch.set_facecolor(self.backgroundColor) + + self.axes = self.fig.add_subplot(111, polar=True, facecolor=self.backgroundColor) + + # Create an "invisible" line at 90 to set the max for the plot + self.axes.plot(np.linspace(0, 2*np.pi, 90), np.ones(90)*90, color=self.scaleColor, + linestyle='') + + # Plot line: Initialize out to 90 and blank + radius = 90 + self.blackline = self.axes.plot(np.linspace(0, 2*np.pi, 90), np.ones(90)*radius, + color=self.scaleColor, linestyle='-') + self.reddot = None + + # Rotate zero up + self.axes.set_theta_zero_location("N") + + # Set limits: + self.axes.set_rlim(0, 90) + + self.axes.set_yticklabels([], color=self.scaleColor) + self.axes.set_xticklabels(['0', '315', '270', '225', '180', '135', '90', '45'], + color=self.scaleColor) + + FigureCanvas.__init__(self, self.fig) + self.setParent(Parent) + + self.title = self.fig.suptitle(self.lbl, fontsize=8, fontweight='bold', color='black') + + FigureCanvas.setSizePolicy(self, + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding) + + self.setMinimumSize(240, 230) + FigureCanvas.updateGeometry(self) + + def msgHandler(self, msg): + new_val = None + + try: + new_val = pmt.to_python(pmt.car(msg)) + if new_val is not None: + if type(new_val) == dict: + if 'az' in new_val and 'el' in new_val: + self.updateData(float(new_val['az']), float(new_val['el'])) + else: + gr.log.error("az and el keys were not found in the dictionary.") + else: + gr.log.error("Value received was not a dictionary. Expecting a dictionary " + "in the car message component with az and el keys.") + else: + gr.log.error("The CAR section of the inbound message was None. " + "This part should contain the dictionary with 'az' and 'el' float keys.") + except Exception as e: + gr.log.error("[AzElPlot] Error with message conversion: %s" % str(e)) + if new_val is not None: + gr.log.error(str(new_val)) + + def updateData(self, azimuth, elevation): + if self.reddot is not None: + self.reddot.pop(0).remove() + + # Plot is angle, radius where angle is in radians + + if (elevation > 0): + # Need to reverse elevation. 90 degrees is center (directly overhead), + # and 90 degrees is horizon. + if (elevation > 90.0): + elevation = 90.0 + + convertedElevation = 90.0 - elevation + # Note: +azimuth for the plot is measured counter-clockwise, so need to reverse it. + self.reddot = self.axes.plot(-azimuth * math.pi/180.0, convertedElevation, self.dotColor, + markersize=8) + else: + # It's below the horizon. Show an open circle at the perimeter + elevation = 0.0 + self.reddot = self.axes.plot(-azimuth * math.pi/180.0, 89.0, self.dotColor, + markerfacecolor="None", markersize=16, fillstyle=None) + + self.draw() + diff --git a/gr-qtgui/python/qtgui/compass.py b/gr-qtgui/python/qtgui/compass.py new file mode 100644 index 0000000000..2c8a766f14 --- /dev/null +++ b/gr-qtgui/python/qtgui/compass.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2020 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# + +import time +import numpy +from gnuradio import gr +import pmt + +# First Qt and 2nd Qt are different. You'll get errors if they're both not available, +# hence the import-as to avoid name collisions + +from PyQt5 import Qt +from PyQt5.QtCore import Qt as Qtc +from PyQt5.QtCore import pyqtSignal, QPoint, pyqtProperty +from PyQt5.QtWidgets import QFrame, QWidget, QVBoxLayout, QHBoxLayout, QLabel +from PyQt5.QtGui import QPainter, QPalette, QFont, QFontMetricsF, QPen, QPolygon, QColor, QBrush + +NeedleFull = 1 +NeedleIndicator = 0 +NeedleMirrored = 2 + +class LabeledCompass(QFrame): + def __init__(self, lbl, min_size, update_time, setDebug=False, + needleType=NeedleFull, position=1, backgroundColor='default'): + # Positions: 1 = above, 2=below, 3=left, 4=right + QFrame.__init__(self) + self.numberControl = Compass(min_size, update_time, setDebug, + needleType, position, backgroundColor) + + if position < 3: + layout = QVBoxLayout() + else: + layout = QHBoxLayout() + + self.lbl = lbl + self.lblcontrol = QLabel(lbl, self) + self.lblcontrol.setAlignment(Qtc.AlignCenter) + + # add top or left + if lbl: + if position == 1 or position == 3: + layout.addWidget(self.lblcontrol) + else: + self.hasLabel = False + + layout.addWidget(self.numberControl) + + # Add bottom or right + if lbl: + if position == 2 or position == 4: + layout.addWidget(self.lblcontrol) + + layout.setAlignment(Qtc.AlignCenter | Qtc.AlignVCenter) + self.setLayout(layout) + + if lbl: + self.setMinimumSize(min_size+30, min_size+35) + else: + self.setMinimumSize(min_size, min_size) + + self.show() + def change_angle(self, angle): + self.numberControl.change_angle(angle) + + def setColors(self, backgroundColor='default', needleTip='red', needleBody='black', + scaleColor='black'): + self.numberControl.setColors(backgroundColor, needleTip, needleBody, scaleColor) + +class Compass(QWidget): + angleChanged = pyqtSignal(float) + + def __init__(self, min_size, update_time, setDebug=False, needleType=NeedleFull, + position=1, backgroundColor='default'): + QWidget.__init__(self, None) + + # Set parameters + self.debug = setDebug + self.needleType = needleType + self.update_period = update_time + self.last = time.time() + self.next_angle = 0 + + self._angle = 0.0 + self._margins = 2 + self._pointText = {0: "0", 45: "45", 90: "90", 135: "135", 180: "180", + 225: "225", 270: "270", 315: "315"} + + self.setMinimumSize(min_size, min_size) + self.setSizePolicy(Qt.QSizePolicy.Expanding, Qt.QSizePolicy.Expanding) + + self.backgroundColor = backgroundColor + self.needleTipColor = 'red' + self.needleBodyColor = 'black' + self.scaleColor = 'black' + + def setColors(self, backgroundColor='default', needleTipColor='red', needleBodyColor='black', + scaleColor='black'): + self.backgroundColor = backgroundColor + self.needleTipColor = needleTipColor + self.needleBodyColor = needleBodyColor + self.scaleColor = scaleColor + + super().update() + + def paintEvent(self, event): + painter = QPainter() + painter.begin(self) + painter.setRenderHint(QPainter.Antialiasing) + + if self.backgroundColor == 'default': + painter.fillRect(event.rect(), self.palette().brush(QPalette.Window)) + else: + size = self.size() + center_x = size.width()/2 + diameter = size.height() + brush = QBrush(QColor(self.backgroundColor), Qtc.SolidPattern) + painter.setBrush(brush) + painter.setPen(QPen(QColor(self.scaleColor), 2)) + painter.setRenderHint(QPainter.Antialiasing) + painter.drawEllipse(center_x-diameter/2+1, 1, diameter-4, diameter-4) + + self.drawMarkings(painter) + self.drawNeedle(painter) + + painter.end() + + def drawMarkings(self, painter): + painter.save() + painter.translate(self.width()/2, self.height()/2) + scale = min((self.width() - self._margins)/120.0, + (self.height() - self._margins)/120.0) + painter.scale(scale, scale) + + font = QFont(self.font()) + font.setPixelSize(8) + metrics = QFontMetricsF(font) + + painter.setFont(font) + painter.setPen(QPen(QColor(self.scaleColor))) + tickInterval = 5 + i = 0 + while i < 360: + + if i % 45 == 0: + painter.drawLine(0, -40, 0, -50) + painter.drawText(-metrics.width(self._pointText[i])/2.0, -52, self._pointText[i]) + else: + painter.drawLine(0, -45, 0, -50) + + painter.rotate(tickInterval) + i += tickInterval + + painter.restore() + + def drawNeedle(self, painter): + painter.save() + # Set up painter + painter.translate(self.width()/2, self.height()/2) + scale = min((self.width() - self._margins)/120.0, + (self.height() - self._margins)/120.0) + painter.scale(scale, scale) + painter.setPen(QPen(Qtc.NoPen)) + + # Rotate surface for painting + intAngle = int(round(self._angle)) + painter.rotate(intAngle) + + # Draw the full black needle first if needed + if self.needleType == NeedleFull: + needleTailBrush = self.palette().brush(QPalette.Shadow) + needleTailColor = QColor(self.needleBodyColor) + needleTailBrush.setColor(needleTailColor) + painter.setBrush(needleTailBrush) + + painter.drawPolygon(QPolygon([QPoint(-6, 0), QPoint(0, -45), QPoint(6, 0), + QPoint(0, 45), QPoint(-6, 0)])) + + # Now draw the red tip (on top of the black needle) + needleTipBrush = self.palette().brush(QPalette.Highlight) + needleTipColor = QColor(self.needleTipColor) + needleTipBrush.setColor(needleTipColor) + painter.setBrush(needleTipBrush) + + # First QPoint is the center bottom apex of the needle + painter.drawPolygon(QPolygon([QPoint(-3, -24), QPoint(0, -45), QPoint(3, -23), + QPoint(0, -30), QPoint(-3, -23)])) + + if self.needleType == NeedleMirrored: + # Rotate + # Need to account for the initial rotation to see how much more to rotate it. + if (intAngle == 90 or intAngle == -90 or intAngle == 270): + mirrorRotation = 180 + else: + mirrorRotation = 180 - intAngle - intAngle + painter.rotate(mirrorRotation) + + # Paint shadowed indicator + needleTipBrush = self.palette().brush(QPalette.Highlight) + needleTipColor = Qtc.gray + needleTipBrush.setColor(needleTipColor) + painter.setBrush(needleTipBrush) + + painter.drawPolygon( + QPolygon([QPoint(-3, -25), QPoint(0, -45), QPoint(3, -25), + QPoint(0, -30), QPoint(-3, -25)]) + ) + + painter.restore() + + def angle(self): + return self._angle + + def change_angle(self, angle): + if angle != self._angle: + if self.debug: + gr.log.info(("Compass angle: " + str(angle))) + + if angle < 0.0: + angle = 360.0 + angle # Angle will already be negative + + self._angle = angle + self.angleChanged.emit(angle) + self.update() + + angle = pyqtProperty(float, angle, change_angle) + + +class GrCompass(gr.sync_block, LabeledCompass): + """ + This block takes angle in degrees as input and displays it on a compass. + Three different needle formats are available, Full, indicator only, + and mirrored (mirrored is useful for direction-finding where an + ambiguity exists in front/back detection angle). + """ + def __init__(self, title, min_size, update_time, setDebug=False, needleType=NeedleFull, + usemsg=False, position=1, backgroundColor='default'): + if usemsg: + gr.sync_block.__init__(self, name="QTCompass", in_sig=[], out_sig=[]) + else: + gr.sync_block.__init__(self, name="QTCompass", in_sig=[numpy.float32], out_sig=[]) + + LabeledCompass.__init__(self, title, min_size, update_time, setDebug, needleType, + position, backgroundColor) + + self.last = time.time() + self.update_period = update_time + self.useMsg = usemsg + + self.next_angle = 0.0 + + self.message_port_register_in(pmt.intern("angle")) + self.set_msg_handler(pmt.intern("angle"), self.msgHandler) + + def msgHandler(self, msg): + try: + new_val = pmt.to_python(pmt.cdr(msg)) + + if type(new_val) == float or type(new_val) == int: + super().change_angle(float(new_val)) + else: + gr.log.error("Value received was not an int or a float: %s" % str(type(new_val))) + + except Exception as e: + gr.log.error("Error with message conversion: %s" % str(e)) + + def setColors(self, backgroundColor='default', needleTip='red', needleBody='black', + scaleColor='black'): + super().setColors(backgroundColor, needleTip, needleBody, scaleColor) + + def work(self, input_items, output_items): + if self.useMsg: + return len(input_items[0]) + + # Average inputs + self.next_angle = numpy.mean(input_items[0]) + + if (time.time() - self.last) > self.update_period: + self.last = time.time() + super().change_angle(self.next_angle) + + # Consume all inputs + return len(input_items[0]) diff --git a/gr-qtgui/python/qtgui/dialcontrol.py b/gr-qtgui/python/qtgui/dialcontrol.py new file mode 100644 index 0000000000..e5c7618b9f --- /dev/null +++ b/gr-qtgui/python/qtgui/dialcontrol.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2020 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# + +from PyQt5.QtWidgets import QFrame, QVBoxLayout, QLabel +from PyQt5 import Qt +from PyQt5.QtCore import Qt as Qtc +from PyQt5.QtCore import QSize +from gnuradio import gr +import pmt + +class LabeledDialControl(QFrame): + def __init__(self, lbl='', parent=None, minimum=0, maximum=100, defaultvalue=0, + backgroundColor='default', changedCallback=None, + minsize=100, isFloat=False, scaleFactor=1, showvalue=False, + outputmsgname='value'): + QFrame.__init__(self, parent) + self.numberControl = DialControl(minimum, maximum, defaultvalue, backgroundColor, + self.valChanged, changedCallback, minsize) + + layout = QVBoxLayout() + + self.outputmsgname = outputmsgname + self.showvalue = showvalue + self.isFloat = isFloat + self.scaleFactor = scaleFactor + self.lbl = lbl + self.lblcontrol = QLabel(lbl, self) + self.lblcontrol.setAlignment(Qtc.AlignCenter) + + if self.showvalue: + textstr = self.buildTextStr(defaultvalue*self.scaleFactor) + self.lblcontrol.setText(textstr) + + if len or self.showvalue: + self.hasLabel = True + layout.addWidget(self.lblcontrol) + else: + self.hasLabel = False + + layout.addWidget(self.numberControl) + + layout.setAlignment(Qtc.AlignCenter) + self.setLayout(layout) + self.show() + + def buildTextStr(self, new_value): + textstr = "" + if self.lbl: + textstr = self.lbl + " - " + + if self.isFloat: + textstr += "%.2f" % (new_value) + else: + textstr += str(new_value) + + return textstr + + def valChanged(self, new_value): + if not self.showvalue: + return + + if int(self.scaleFactor) != 1: + new_value = new_value * self.scaleFactor + + textstr = self.buildTextStr(new_value) + self.lblcontrol.setText(textstr) + +class DialControl(Qt.QDial): + def __init__(self, minimum=0, maximum=100, defaultvalue=0, backgroundColor='default', + lablelCallback=None, changedCallback=None, minsize=100): + Qt.QDial.__init__(self) + + if backgroundColor != "default": + self.setStyleSheet("background-color: " + backgroundColor + ";") + + self.minsize = minsize + self.changedCallback = changedCallback + self.lablelCallback = lablelCallback + super().setMinimum(minimum) + super().setMaximum(maximum) + super().setValue(defaultvalue) + super().valueChanged.connect(self.sliderMoved) + + def minimumSizeHint(self): + return QSize(self.minsize, self.minsize) + + def sliderMoved(self): + if self.changedCallback is not None: + self.changedCallback(self.value()) + + if self.lablelCallback is not None: + self.lablelCallback(self.value()) + +class GrDialControl(gr.sync_block, LabeledDialControl): + """ + This block creates a dial control. The control does control a + variable which can be used for other items. Leave the label + blank to use the variable id as the label. The block also + creates an optional message with the control value that + can be used in message-based applications. + + Note: Dials only produce integer values, so the scale factor + can be used with the min/max to adjust the output value to + the desired range. Think of the min/max as the increments, + and the scale factor as the adjustment to get the values you want. + """ + def __init__(self, lbl, parent, minimum, maximum, defaultvalue, backgroundColor='default', + varCallback=None, isFloat=False, + scaleFactor=1, minsize=100, showvalue=False, outputmsgname='value'): + gr.sync_block.__init__(self, name="GrDialControl", in_sig=None, out_sig=None) + LabeledDialControl.__init__(self, lbl, parent, minimum, maximum, defaultvalue, + backgroundColor, self.valueChanged, minsize, isFloat, + scaleFactor, showvalue) + + self.outputmsgname = outputmsgname + self.varCallback = varCallback + self.scaleFactor = scaleFactor + self.isFloat = isFloat + self.message_port_register_out(pmt.intern("value")) + + def valueChanged(self, new_value): + if int(self.scaleFactor) != 1: + new_value = new_value * self.scaleFactor + + if self.varCallback is not None: + self.varCallback(new_value) + + if self.isFloat: + self.message_port_pub(pmt.intern("value"), pmt.cons(pmt.intern(self.outputmsgname), + pmt.from_float(new_value))) + else: + self.message_port_pub(pmt.intern("value"), pmt.cons(pmt.intern(self.outputmsgname), + pmt.from_long(new_value))) + diff --git a/gr-qtgui/python/qtgui/dialgauge.py b/gr-qtgui/python/qtgui/dialgauge.py new file mode 100644 index 0000000000..af96f2e096 --- /dev/null +++ b/gr-qtgui/python/qtgui/dialgauge.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2020 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# + +import sys +from PyQt5.QtWidgets import QFrame, QHBoxLayout, QVBoxLayout, QLabel +from PyQt5.QtGui import QPainter, QColor, QPen, QFont, QFontMetricsF +from PyQt5 import QtCore +from PyQt5.QtCore import Qt as Qtc + +from gnuradio import gr +import pmt + +class LabeledDialGauge(QFrame): + # Positions: 1 = above, 2=below, 3=left, 4=right + def __init__(self, lbl='', barColor='blue', backgroundColor='white', fontColor='black', + minValue=0, maxValue=100, maxSize=80, position=1, + isFloat=False, showValue=False, fixedOrMin=True, parent=None): + QFrame.__init__(self, parent) + self.numberControl = DialGauge(barColor, backgroundColor, fontColor, minValue, + maxValue, maxSize, isFloat, showValue, fixedOrMin, parent) + + if position < 3: + layout = QVBoxLayout() + else: + layout = QHBoxLayout() + + self.lbl = lbl + self.showvalue = showValue + self.isFloat = isFloat + + self.lblcontrol = QLabel(lbl, self) + self.lblcontrol.setAlignment(Qtc.AlignCenter) + + # For whatever reason, the progressbar doesn't show the number in the bar if it's + # vertical, only if it's horizontal + if len: + self.lblcontrol.setText(lbl) + + if fontColor != 'default': + self.lblcontrol.setStyleSheet("QLabel { color : " + fontColor + "; }") + + # add top or left + if len: + if position == 1 or position == 3: + layout.addWidget(self.lblcontrol) + else: + self.hasLabel = False + + layout.addWidget(self.numberControl) + + # Add bottom or right + if len: + if position == 2 or position == 4: + layout.addWidget(self.lblcontrol) + + layout.setAlignment(Qtc.AlignCenter | Qtc.AlignVCenter) + self.setLayout(layout) + + self.show() + + def setValue(self, new_value): + self.numberControl.setValue(new_value) + +class DialGauge(QFrame): + def __init__(self, barColor='blue', backgroundColor='white', fontColor='black', + minValue=0, maxValue=100, maxSize=80, + isFloat=False, showValue=False, fixedOrMin=True, parent=None): + QFrame.__init__(self, parent) + + self.maxSize = maxSize + super().setMinimumSize(maxSize, maxSize) + if fixedOrMin: + super().setMaximumSize(maxSize, maxSize) + + self.backgroundColor = backgroundColor + self.barColor = barColor + self.fontColor = fontColor + self.isFloat = isFloat + self.showValue = showValue + + self.value = minValue + + self.minValue = minValue + self.maxValue = maxValue + + self.textfont = QFont(self.font()) + self.textfont.setPixelSize(16) + self.metrics = QFontMetricsF(self.textfont) + + self.startAngle = 0.0 + self.endAngle = 360.0 + self.degScaler = 16.0 # The span angle must be specified in 1/16 of a degree units + self.penWidth = max(int(0.1 * maxSize), 6) + self.halfPenWidth = int(self.penWidth / 2) + + def getValue(self): + if self.isFloat: + return float(self.value) + else: + return int(self.value) + + def setValue(self, new_value): + if new_value > self.maxValue: + new_value = self.maxValue + elif new_value < self.minValue: + new_value = self.minValue + + self.value = float(new_value) + + super().update() + + def paintEvent(self, event): + super().paintEvent(event) + + size = self.size() + + percentRange = float(self.value - self.minValue) / float(self.maxValue - self.minValue) + endAngle = self.startAngle + round(percentRange * float(self.endAngle - self.startAngle), 0) + + # Now convert angles to 1/16 scale + startAngle = int(round(self.startAngle * self.degScaler, 0)) + endAngle = int(round(endAngle * self.degScaler, 0)) + + rect = QtCore.QRect(self.halfPenWidth, self.halfPenWidth, size.width()-self.penWidth, + size.height()-self.penWidth) + + # Set up the painting canvass + painter = QPainter() + painter.begin(self) + painter.setRenderHint(QPainter.Antialiasing) + + if self.showValue: + painter.setFont(self.textfont) + painter.setPen(QPen(QColor(self.fontColor))) + + if self.isFloat: + printText = "%.2f" % self.value + else: + printText = str(int(self.value)) + + painter.drawText(size.width()/2-self.metrics.width(printText)/2, size.height()/2, + printText) + + painter.save() + painter.translate(self.width(), 0) + painter.rotate(90.0) + + # First draw complete circle + painter.setPen(QPen(QColor(self.backgroundColor), self.penWidth)) + painter.drawArc(rect, startAngle, self.endAngle*self.degScaler) + # First draw complete circle + painter.setPen(QPen(QColor(self.barColor), self.penWidth)) + painter.drawArc(rect, startAngle, -endAngle) + painter.setPen(QPen(QColor('darkgray'), 2)) + painter.drawEllipse(1, 1, rect.width()+self.penWidth-2, rect.width()+self.penWidth-2) + painter.drawEllipse(1+self.penWidth, 1+self.penWidth, rect.width()-self.penWidth-2, + rect.width()-self.penWidth-2) + painter.restore() + + painter.end() + +class GrDialGauge(gr.sync_block, LabeledDialGauge): + """ + This block creates a dial-style gauge. The value can be set + either with a variable or an input message. + """ + def __init__(self, lbl='', barColor='blue', backgroundColor='white', fontColor='black', + minValue=0, maxValue=100, maxSize=80, + position=1, isFloat=False, showValue=False, fixedOrMin=True, parent=None): + gr.sync_block.__init__(self, name="DialGauge", in_sig=None, out_sig=None) + LabeledDialGauge.__init__(self, lbl, barColor, backgroundColor, fontColor, minValue, + maxValue, maxSize, position, isFloat, showValue, fixedOrMin, + parent) + self.lbl = lbl + + if minValue > maxValue: + gr.log.error("Min value is greater than max value.") + sys.exit(1) + + self.message_port_register_in(pmt.intern("value")) + self.set_msg_handler(pmt.intern("value"), self.msgHandler) + + + def msgHandler(self, msg): + try: + new_val = pmt.to_python(pmt.cdr(msg)) + + if type(new_val) == float or type(new_val) == int: + super().setValue(new_val) + else: + gr.log.error("Value received was not an int or a float. " + "Received %s" % str(type(new_val))) + + except Exception as e: + gr.log.error("Error with message conversion: %s" % str(e)) + + + def setValue(self, new_value): + super().setValue(new_value) + diff --git a/gr-qtgui/python/qtgui/digitalnumbercontrol.py b/gr-qtgui/python/qtgui/digitalnumbercontrol.py new file mode 100644 index 0000000000..ea8bdd7eb5 --- /dev/null +++ b/gr-qtgui/python/qtgui/digitalnumbercontrol.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2020 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# + +from PyQt5.QtWidgets import QFrame, QVBoxLayout, QLabel +from PyQt5.QtGui import QPainter, QPixmap, QFont, QFontMetrics, QBrush, QColor +from PyQt5.QtCore import Qt, QSize +from PyQt5 import QtCore +from PyQt5.QtCore import Qt as Qtc +from PyQt5.QtCore import pyqtSignal + +from gnuradio import gr +import pmt + +# -------------- Support Classes --------------------------------- +# +# + +class LabeledDigitalNumberControl(QFrame): + def __init__(self, lbl='', min_freq_hz=0, max_freq_hz=6000000000, parent=None, + thousands_separator=',', background_color='black', fontColor='white', + click_callback=None): + QFrame.__init__(self, parent) + self.numberControl = DigitalNumberControl(min_freq_hz, max_freq_hz, self, + thousands_separator, background_color, fontColor, click_callback) + + layout = QVBoxLayout() + + self.lbl = QLabel(lbl, self) + if len: + self.hasLabel = True + layout.addWidget(self.lbl) + else: + self.hasLabel = False + + layout.addWidget(self.numberControl) + layout.setAlignment(Qtc.AlignCenter | Qtc.AlignVCenter) + self.setLayout(layout) + self.show() + + def minimumSizeHint(self): + if self.hasLabel: + return QSize(self.numberControl.minimumWidth()+10, 100) + else: + return QSize(self.numberControl.minimumWidth()+10, 50) + + def setReadOnly(self, b_read_only): + self.numberControl.setReadOnly(b_read_only) + + def setFrequency(self, new_freq): + self.numberControl.setFrequency(new_freq) + + def getFrequency(self): + return self.numberControl.getFrequency() + +class DigitalNumberControl(QFrame): + # Notifies to avoid thread conflicts on paints + updateInt = pyqtSignal(int) + updateFloat = pyqtSignal(float) + + def __init__(self, min_freq_hz=0, max_freq_hz=6000000000, parent=None, thousands_separator=',', + background_color='black', fontColor='white', click_callback=None): + QFrame.__init__(self, parent) + + self.updateInt.connect(self.onUpdateInt) + self.updateFloat.connect(self.onUpdateFloat) + + self.min_freq = int(min_freq_hz) + self.max_freq = int(max_freq_hz) + self.numDigitsInFreq = len(str(max_freq_hz)) + + self.thousands_separator = thousands_separator + self.click_callback = click_callback + + self.read_only = False + + self.setColors(QColor(background_color), QColor(fontColor)) + self.numberFont = QFont("Arial", 12, QFont.Normal) + + self.cur_freq = min_freq_hz + + self.debug_click = False + + # Determine what our width minimum is + teststr = "" + for i in range(0, self.numDigitsInFreq): + teststr += "0" + + fm = QFontMetrics(self.numberFont) + if len(self.thousands_separator) > 0: + # The -1 makes sure we don't count an extra for 123,456,789. Answer should be 2 not 3. + numgroups = int(float(self.numDigitsInFreq-1) / 3.0) + if numgroups > 0: + for i in range(0, numgroups): + teststr += self.thousands_separator + + textstr = teststr + else: + textstr = teststr + + width = fm.width(textstr) + + self.minwidth = width + + if self.minwidth < 410: + self.minwidth = 410 + + self.setMaximumHeight(70) + self.setMinimumWidth(self.minwidth) + # Show the control + self.show() + + def minimumSizeHint(self): + return QSize(self.minwidth, 50) + + def setReadOnly(self, b_read_only): + self.read_only = b_read_only + + def mousePressEvent(self, event): + super(DigitalNumberControl, self).mousePressEvent(event) + self.offset = event.pos() + + if self.read_only: + return + + fm = QFontMetrics(self.numberFont) + + if len(self.thousands_separator) > 0: + if self.thousands_separator != ".": + textstr = format(self.getFrequency(), self.thousands_separator) + else: + textstr = format(self.getFrequency(), ",") + textstr = textstr.replace(",", ".") + else: + textstr = str(self.getFrequency()) + + width = fm.width(textstr) + + # So we know: + # - the width of the text + # - The mouse click position relative to 0 (pos relative to string start + # will be size().width() - 2 - pos.x + + clickpos = self.size().width() - 2 - self.offset.x() + + found_number = False + clicked_thousands = False + for i in range(1, len(textstr)+1): + width = fm.width(textstr[-i:]) + charstr = textstr[-i:] + widthchar = fm.width(charstr[0]) + if clickpos >= (width-widthchar) and clickpos <= width: + clicked_char = i-1 + + clicked_num_index = clicked_char + + found_number = True + + if len(self.thousands_separator) > 0: + if charstr[0] != self.thousands_separator: + numSeps = charstr.count(self.thousands_separator) + clicked_num_index -= numSeps + if self.debug_click: + gr.log.info("clicked number: " + str(clicked_num_index)) + else: + clicked_thousands = True + if self.debug_click: + gr.log.info("clicked thousands separator") + else: + if self.debug_click: + gr.log.info("clicked number: " + str(clicked_char)) + + # Remember y=0 is at the top so this is reversed + clicked_up = False + if self.offset.y() > self.size().height()/2: + if self.debug_click: + gr.log.info('clicked down') + else: + if self.debug_click: + gr.log.info('clicked up') + clicked_up = True + + if not clicked_thousands: + cur_freq = self.getFrequency() + increment = pow(10, clicked_num_index) + if clicked_up: + cur_freq += increment + else: + cur_freq -= increment + + self.setFrequency(cur_freq) + if self.click_callback is not None: + self.click_callback(self.getFrequency()) + + break + + if (not found_number) and (not clicked_thousands): + # See if we clicked in the high area, if so, increment there. + clicked_up = False + if self.offset.y() > self.size().height()/2: + if self.debug_click: + gr.log.info('clicked down in the high area') + else: + if self.debug_click: + gr.log.info('clicked up in the high area') + clicked_up = True + + textstr = str(self.getFrequency()) + numNumbers = len(textstr) + increment = pow(10, numNumbers) + cur_freq = self.getFrequency() + if clicked_up: + cur_freq += increment + else: + cur_freq -= increment + + self.setFrequency(cur_freq) + if self.click_callback is not None: + self.click_callback(self.getFrequency()) + + def setColors(self, background, fontColor): + self.background_color = background + self.fontColor = fontColor + + def reverseString(self, astring): + astring = astring[::-1] + return astring + + def onUpdateInt(self, new_freq): + if (new_freq >= self.min_freq) and (new_freq <= self.max_freq): + self.cur_freq = int(new_freq) + + self.update() + + def onUpdateFloat(self, new_freq): + if (new_freq >= self.min_freq) and (new_freq <= self.max_freq): + self.cur_freq = int(new_freq) + + self.update() + + def setFrequency(self, new_freq): + if type(new_freq) == int: + self.updateInt.emit(new_freq) + else: + self.updateFloat.emit(new_freq) + + def getFrequency(self): + return self.cur_freq + + def resizeEvent(self, event): + self.pxMap = QPixmap(self.size()) + self.pxMap.fill(self.background_color) + + self.update() + + def paintEvent(self, event): + super().paintEvent(event) + + painter = QPainter(self) + + size = self.size() + brush = QBrush() + brush.setColor(self.background_color) + brush.setStyle(Qt.SolidPattern) + rect = QtCore.QRect(2, 2, size.width()-4, size.height()-4) + painter.fillRect(rect, brush) + + self.numberFont.setPixelSize(0.9 * size.height()) + painter.setFont(self.numberFont) + painter.setPen(self.fontColor) + rect = event.rect() + + if len(self.thousands_separator) > 0: + if self.thousands_separator != ".": + textstr = format(self.getFrequency(), self.thousands_separator) + else: + textstr = format(self.getFrequency(), ",") + textstr = textstr.replace(",", ".") + else: + textstr = str(self.getFrequency()) + rect = QtCore.QRect(0, 0, size.width()-4, size.height()) + painter.drawText(rect, Qt.AlignRight + Qt.AlignVCenter, textstr) + +# ################################################################################ + +# GNU Radio Class +class MsgDigitalNumberControl(gr.sync_block, LabeledDigitalNumberControl): + def __init__(self, lbl='', min_freq_hz=0, max_freq_hz=6000000000, parent=None, + thousands_separator=',', background_color='black', fontColor='white', + var_callback=None, outputmsgname='freq'): + gr.sync_block.__init__(self, name="MsgDigitalNumberControl", + in_sig=None, out_sig=None) + LabeledDigitalNumberControl.__init__(self, lbl, min_freq_hz, max_freq_hz, parent, + thousands_separator, background_color, fontColor, self.click_callback) + + self.var_callback = var_callback + self.outputmsgname = outputmsgname + + self.message_port_register_in(pmt.intern("valuein")) + self.set_msg_handler(pmt.intern("valuein"), self.msgHandler) + self.message_port_register_out(pmt.intern("valueout")) + + def msgHandler(self, msg): + try: + new_val = pmt.to_python(pmt.cdr(msg)) + + if type(new_val) == float or type(new_val) == int: + self.call_var_callback(new_val) + + self.setValue(new_val) + else: + gr.log.error("Value received was not an int or a float. %s" % str(type(new_val))) + + except Exception as e: + gr.log.error("Error with message conversion: %s" % str(e)) + + def call_var_callback(self, new_value): + if (self.var_callback is not None): + if type(self.var_callback) is float: + self.var_callback = float(new_value) + else: + self.var_callback(float(new_value)) + + def click_callback(self, new_value): + self.call_var_callback(new_value) + + self.message_port_pub(pmt.intern("valueout"), pmt.cons(pmt.intern(self.outputmsgname), pmt.from_float(float(new_value)))) + + def setValue(self, new_val): + self.setFrequency(new_val) + + self.message_port_pub(pmt.intern("valueout"), pmt.cons(pmt.intern(self.outputmsgname), pmt.from_float(float(self.getFrequency())))) + + def getValue(self): + self.getFrequency() + + def setReadOnly(self, b_read_only): + super().setReadOnly(b_read_only) + diff --git a/gr-qtgui/python/qtgui/distanceradar.py b/gr-qtgui/python/qtgui/distanceradar.py new file mode 100644 index 0000000000..170cf81f40 --- /dev/null +++ b/gr-qtgui/python/qtgui/distanceradar.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2020 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# + +import sys +from PyQt5 import QtWidgets +import numpy as np +import matplotlib.pyplot as plt +try: + from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas + from matplotlib.figure import Figure +except: + gr.log.error("Unable to import matplotlib. Please install matplotlib first " + "(e.g., via pip/pip3/dpkg/MacPorts).") + sys.exit(1) + +from gnuradio import gr +import pmt + +class DistanceRadar(gr.sync_block, FigureCanvas): + """ + This block creates a radar-like screen used to represent distance or size. + This can be used in many ways such as circles closer to the center are + closer, or just the opposite where closer to the center is smaller. + + Note: Incoming values should range between 0 (center bullseye) and + 100 (all the way out) + """ + def __init__(self, lbl, ticklabels, backgroundColor, fontColor, ringColor, Parent=None, + width=4, height=4, dpi=100): + gr.sync_block.__init__(self, name="distanceradar", in_sig=None, out_sig=None) + + self.lbl = lbl + + self.message_port_register_in(pmt.intern("radius")) + self.set_msg_handler(pmt.intern("radius"), self.msgHandler) + + self.fontColor = fontColor + self.backgroundColor = backgroundColor + self.ringColor = ringColor + + self.fig = Figure(figsize=(width, height), dpi=dpi) + self.fig.patch.set_facecolor(self.backgroundColor) + self.axes = self.fig.add_subplot(111, polar=True, facecolor=self.backgroundColor) + + # Create an "invisible" line at 100 to set the max for the plot + self.axes.plot(np.linspace(0, 2*np.pi, 100), np.ones(100)*100, color=self.fontColor, + linestyle='') + + # Plot line: Initialize out to 100 and blank + radius = 100 + self.blackline = self.axes.plot(np.linspace(0, 2*np.pi, 100), np.ones(100)*radius, + color=self.fontColor, linestyle='-') + self.redline = None + + self.filledcircle = None + # Create bullseye + circle = plt.Circle((0.0, 0.0), 20, transform=self.axes.transData._b, color=self.fontColor, + alpha=0.4) + self.bullseye = self.axes.add_artist(circle) + + # Rotate zero up + self.axes.set_theta_zero_location("N") + + self.axes.set_yticklabels(ticklabels, color=self.fontColor) + self.axes.set_xticklabels([], color=self.fontColor) + + FigureCanvas.__init__(self, self.fig) + self.setParent(Parent) + + + self.title = self.fig.suptitle(self.lbl, fontsize=8, fontweight='bold', + color=self.fontColor) + + FigureCanvas.setSizePolicy(self, + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding) + FigureCanvas.updateGeometry(self) + + def msgHandler(self, msg): + try: + new_val = pmt.to_python(pmt.cdr(msg)) + + if type(new_val) == float or type(new_val) == int: + self.updateData(new_val) + else: + gr.log.error("Value received was not an int or a " + "float: %s" % str(type(new_val))) + + except Exception as e: + gr.log.error("Error with message conversion: %s" % str(e)) + + def updateData(self, radius): + if self.redline is not None: + self.redline.pop(0).remove() + self.redline = self.axes.plot(np.linspace(0, 2*np.pi, 100), np.ones(100)*radius, + color='r', linestyle='-') + + if self.filledcircle: + self.filledcircle.remove() + + self.bullseye.remove() + circle = plt.Circle((0.0, 0.0), radius, transform=self.axes.transData._b, + color=self.ringColor, alpha=0.4) + self.filledcircle = self.axes.add_artist(circle) + # Create bullseye + circle = plt.Circle((0.0, 0.0), 20, transform=self.axes.transData._b, + color=self.fontColor, alpha=0.4) + self.bullseye = self.axes.add_artist(circle) + + self.draw() + diff --git a/gr-qtgui/python/qtgui/graphicitem.py b/gr-qtgui/python/qtgui/graphicitem.py new file mode 100644 index 0000000000..b1386ab586 --- /dev/null +++ b/gr-qtgui/python/qtgui/graphicitem.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2020 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# + +from PyQt5.QtWidgets import QLabel +from PyQt5.QtGui import QPixmap, QPainter +from PyQt5.QtCore import Qt as Qtc +from PyQt5.QtCore import QSize + +import os +import sys + +from gnuradio import gr +import pmt + +class GrGraphicItem(gr.sync_block, QLabel): + """ + This block displays the selected graphic item. You can pass a + filename as a string in a message to change the image on the fly. + overlays can also be added by passing in a message with a + dictionary of a list of dictionaries in the car portion of the + message. Each dicationary should have the following keys: + 'filename','x','y', and an optional 'scalefactor'. + Setting the x/y attributes to -1,-1 will remove an overlay. + Otherwise items are indexed by filename and can be animated + throughout the backgound image. + """ + def __init__(self, image_file, scaleImage=True, fixedSize=False, setWidth=0, setHeight=0): + gr.sync_block.__init__(self, name="GrGraphicsItem", in_sig=None, out_sig=None) + QLabel.__init__(self) + + if not os.path.isfile(image_file): + gr.log.error("ERROR: Unable to find file " + image_file) + sys.exit(1) + + try: + self.pixmap = QPixmap(image_file) + self.originalPixmap = QPixmap(image_file) + except OSError as e: + gr.log.error("ERROR: " + e.strerror) + sys.exit(1) + + self.image_file = image_file + self.scaleImage = scaleImage + self.fixedSize = fixedSize + self.setWidth = setWidth + self.setHeight = setHeight + super().setPixmap(self.pixmap) + super().setMinimumSize(1, 1) + self.overlays = {} + + self.message_port_register_in(pmt.intern("filename")) + self.set_msg_handler(pmt.intern("filename"), self.msgHandler) + + self.message_port_register_in(pmt.intern("overlay")) + self.set_msg_handler(pmt.intern("overlay"), self.overlayHandler) + + def overlayHandler(self, msg): + try: + overlayitem = pmt.to_python(pmt.car(msg)) + if overlayitem is None: + gr.log.error('Overlay message contains None in the car portion ' + 'of the message. Please pass in a dictionary or list of dictionaries in this ' + 'portion of the message. Each dictionary should have the following keys: ' + 'filename,x,y. Use x=y=-1 to remove an overlay item.') + return + + if type(overlayitem) is dict: + itemlist = [] + itemlist.append(overlayitem) + elif type(overlayitem) is list: + itemlist = overlayitem + else: + gr.log.error("Overlay message type is not correct. Please pass in " + "a dictionary or list of dictionaries in this portion of the message. Each " + "dictionary should have the following keys: filename,x,y. Use x=y=-1 to " + "remove an overlay item.") + return + + # Check each dict item to make sure it's valid. + for curitem in itemlist: + if type(curitem) == dict: + if 'filename' not in curitem: + gr.log.error("Dictionary item did not contain the 'filename' key.") + gr.log.error("Received " + str(curitem)) + continue + + if 'x' not in curitem: + gr.log.error("The dictionary for filename " + + curitem['filename'] + " did not contain an 'x' key.") + gr.log.error("Received " + str(curitem)) + continue + + if 'y' not in curitem: + gr.log.error("The dictionary for filename " + + curitem['filename'] + " did not contain an 'y' key.") + gr.log.error("Received " + str(curitem)) + continue + + if not os.path.isfile(curitem['filename']): + gr.log.error("Unable to find overlay file " + + curitem['filename']) + gr.log.error("Received " + str(curitem)) + continue + + # Now either add/update our list or remove the item. + if curitem['x'] == -1 and curitem['y'] == -1: + try: + del self.overlays[curitem['filename']] # remove item + except: + pass + else: + self.overlays[curitem['filename']] = curitem + + self.updateGraphic() + except Exception as e: + gr.log.error("Error with overlay message conversion: %s" % str(e)) + + def updateGraphic(self): + if (len(self.overlays.keys()) == 0): + try: + super().setPixmap(self.pixmap) + except Exception as e: + gr.log.error("Error updating graphic: %s" % str(e)) + return + else: + # Need to deal with overlays + tmpPxmap = self.pixmap.copy(self.pixmap.rect()) + painter = QPainter(tmpPxmap) + for curkey in self.overlays.keys(): + curOverlay = self.overlays[curkey] + try: + newOverlay = QPixmap(curkey) + if 'scalefactor' in curOverlay: + scale = curOverlay['scalefactor'] + w = newOverlay.width() + h = newOverlay.height() + newOverlay = newOverlay.scaled(int(w*scale), int(h*scale), + Qtc.KeepAspectRatio) + painter.drawPixmap(curOverlay['x'], curOverlay['y'], newOverlay) + except Exception as e: + gr.log.error("Error adding overlay: %s" % str(e)) + return + + painter.end() + + super().setPixmap(tmpPxmap) + + def msgHandler(self, msg): + try: + new_val = pmt.to_python(pmt.cdr(msg)) + image_file = new_val + if type(new_val) == str: + if not os.path.isfile(image_file): + gr.log.error("ERROR: Unable to find file " + image_file) + return + + try: + self.pixmap = QPixmap(image_file) + self.image_file = image_file + except OSError as e: + gr.log.error("ERROR: " + e.strerror) + return + + self.updateGraphic() + else: + gr.log.error("Value received was not an int or " + "a bool: %s" % str(type(new_val))) + + except Exception as e: + gr.log.error("Error with message conversion: %s" % str(e)) + + def minimumSizeHint(self): + return QSize(self.pixmap.width(),self.pixmap.height()) + + def resizeEvent(self, event): + if self.scaleImage: + w = super().width() + h = super().height() + + self.pixmap = self.originalPixmap.scaled(w, h, Qtc.KeepAspectRatio) + elif self.fixedSize and self.setWidth > 0 and self.setHeight > 0: + self.pixmap = self.originalPixmap.scaled(self.setWidth, self.setHeight, + Qtc.KeepAspectRatio) + + self.updateGraphic() + diff --git a/gr-qtgui/python/qtgui/graphicoverlay.py b/gr-qtgui/python/qtgui/graphicoverlay.py new file mode 100644 index 0000000000..defef31d17 --- /dev/null +++ b/gr-qtgui/python/qtgui/graphicoverlay.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2020 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# + +import sys +import threading +import time + +from gnuradio import gr +import pmt + +# This thread just gets us out of the sync_block's init function so the messaging +# system and scheduler are active. +class offloadThread(threading.Thread): + def __init__(self, callback, overlayList, listDelay, repeat): + threading.Thread.__init__(self) + self.callback = callback + self.overlayList = overlayList + self.listDelay = listDelay + self.threadRunning = False + self.stopThread = False + self.repeat = repeat + + def run(self): + self.stopThread = False + self.threadRunning = True + + # Wait for main __init__ to finish + time.sleep(0.5) + + if (type(self.overlayList) == list and self.listDelay > 0.0): + while self.repeat and not self.stopThread: + for curItem in self.overlayList: + self.callback(curItem) + + if self.stopThread: + break + + time.sleep(self.listDelay) + + if self.stopThread: + break + else: + self.callback(self.overlayList) + + self.threadRunning = False + +class GrGraphicOverlay(gr.sync_block): + """ + This block is an example of how to feed an overlay to a graphic item. + The graphic item overlay is expecting a dictionary with the following + keys: 'filename','x','y', and optionally a 'scalefactor'. A list of + dictionaries can also be supplied to support multiple items. + + Any file can be added to the graphic item as an overlay and the + particular item indexed by its filename can be updated by passing + in new x/y coordinates. To remove an overlay, use coordinates -1,-1 + for the x,y coordinates. + + This sample block sends either a dictionary or list of dictionaries + to the graphicitem block. To test updating a single overlay item, + you can use a list with the same file but different coordinates and + use the update delay > 0.0 to animate it. + """ + def __init__(self, overlayList, listDelay, repeat): + gr.sync_block.__init__(self, name="GrGraphicsOverlay", in_sig=None, + out_sig=None) + + self.overlayList = overlayList + self.listDelay = listDelay + if type(self.overlayList) is not dict and type(self.overlayList) is not list: + gr.log.error("The specified input is not valid. " + "Please specify either a dictionary item with the following keys: " + "'filename','x','y'[,'scalefactor'] or a list of dictionary items.") + sys.exit(1) + + self.message_port_register_out(pmt.intern("overlay")) + + self.thread = offloadThread(self.overlayCallback, self.overlayList, listDelay, + repeat) + self.thread.start() + + def overlayCallback(self, msgData): + # Need to let init finish before this can be called so need to thread it out. + meta = pmt.to_pmt(msgData) + pdu = pmt.cons(meta, pmt.PMT_NIL) + self.message_port_pub(pmt.intern('overlay'), pdu) + + def stop(self): + self.thread.stopThread = True + + while self.thread.threadRunning: + time.sleep(0.1) + + return True + diff --git a/gr-qtgui/python/qtgui/ledindicator.py b/gr-qtgui/python/qtgui/ledindicator.py new file mode 100644 index 0000000000..09b2bd8e19 --- /dev/null +++ b/gr-qtgui/python/qtgui/ledindicator.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2020 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# + +from PyQt5.QtWidgets import QFrame, QHBoxLayout, QVBoxLayout, QLabel +from PyQt5.QtGui import QPainter, QBrush, QColor, QPen, QFontMetricsF +from PyQt5.QtCore import Qt as Qtc +from PyQt5.QtCore import QPoint +from PyQt5.QtGui import QRadialGradient + +from gnuradio import gr +import pmt + +class LabeledLEDIndicator(QFrame): + # Positions: 1 = above, 2=below, 3=left, 4=right + def __init__(self, lbl='', onColor='green', offColor='red', initialState=False, maxSize=80, + position=1, alignment=1, valignment=1, parent=None): + QFrame.__init__(self, parent) + self.numberControl = LEDIndicator(onColor, offColor, initialState, maxSize, parent) + + if position < 3: + layout = QVBoxLayout() + else: + layout = QHBoxLayout() + + if not lbl: + lbl = " " + + self.lbl = lbl + self.lblcontrol = QLabel(lbl, self) + self.lblcontrol.setAlignment(Qtc.AlignCenter) + + # add top or left + if len: + if position == 1 or position == 3: + layout.addWidget(self.lblcontrol) + else: + self.hasLabel = False + + layout.addWidget(self.numberControl) + + # Add bottom or right + if len: + if position == 2 or position == 4: + layout.addWidget(self.lblcontrol) + + if alignment == 1: + halign = Qtc.AlignCenter + elif alignment == 2: + halign = Qtc.AlignLeft + else: + halign = Qtc.AlignRight + + if valignment == 1: + valign = Qtc.AlignVCenter + elif valignment == 2: + valign = Qtc.AlignTop + else: + valign = Qtc.AlignBottom + + layout.setAlignment(halign | valign) + self.setLayout(layout) + + if len: + textfont = self.lblcontrol.font() + metrics = QFontMetricsF(textfont) + + maxWidth = max((maxSize+30), (maxSize + metrics.width(lbl)+4)) + maxHeight = max((maxSize+35), (maxSize + metrics.height()+2)) + self.setMinimumSize(maxWidth, maxHeight) + else: + self.setMinimumSize(maxSize+2, maxSize+2) + + self.show() + + def setState(self, on_off): + self.numberControl.setState(on_off) + +class LEDIndicator(QFrame): + def __init__(self, onColor='green', offColor='red', initialState=False, maxSize=80, + parent=None): + QFrame.__init__(self, parent) + + self.maxSize = maxSize + self.curState = initialState + self.on_color = QColor(onColor) + self.off_color = QColor(offColor) + + self.setMinimumSize(maxSize, maxSize) + self.setMaximumSize(maxSize, maxSize) + + def setState(self, on_off): + self.curState = on_off + super().update() + + def paintEvent(self, event): + super().paintEvent(event) + + painter = QPainter(self) + + size = self.size() + brush = QBrush() + + smallest_dim = size.width() + if smallest_dim > size.height(): + smallest_dim = size.height() + + smallest_dim = smallest_dim/2 + smallest_dim -= 2 + + center_x = size.width()/2 + center_y = size.height()/2 + centerpoint = QPoint(center_x, center_y) + + radius = smallest_dim + + painter.setPen(QPen(QColor('lightgray'), 0)) + brush.setStyle(Qtc.SolidPattern) + + radial = QRadialGradient(center_x, center_y/2, radius) + radial.setColorAt(0, Qtc.white) + radial.setColorAt(0.8, Qtc.darkGray) + painter.setBrush(QBrush(radial)) + painter.drawEllipse(centerpoint, radius, radius) + + # Draw the colored center + radial = QRadialGradient(center_x, center_y/2, radius) + radial.setColorAt(0, Qtc.white) + + if self.curState: + radial.setColorAt(.7, self.on_color) + brush.setColor(self.on_color) + painter.setPen(QPen(self.on_color, 0)) + else: + radial.setColorAt(.7, self.off_color) + brush.setColor(self.off_color) + painter.setPen(QPen(self.off_color, 0)) + + brush.setStyle(Qtc.SolidPattern) + painter.setBrush(QBrush(radial)) + if smallest_dim <= 30: + radius = radius - 3 + elif smallest_dim <= 60: + radius = radius - 4 + elif smallest_dim <= 100: + radius = radius - 5 + elif smallest_dim <= 200: + radius = radius - 6 + elif smallest_dim <= 300: + radius = radius - 7 + else: + radius = radius - 9 + painter.drawEllipse(centerpoint, radius, radius) + +class GrLEDIndicator(gr.sync_block, LabeledLEDIndicator): + """ + This block makes a basic LED indicator + """ + def __init__(self, lbl='', onColor='green', offColor='red', initialState=False, + maxSize=80, position=1, alignment=1, valignment=1, parent=None): + gr.sync_block.__init__(self, name="LEDIndicator", in_sig=None, out_sig=None) + LabeledLEDIndicator.__init__(self, lbl, onColor, offColor, initialState, + maxSize, position, alignment, valignment, parent) + self.lbl = lbl + + self.message_port_register_in(pmt.intern("state")) + self.set_msg_handler(pmt.intern("state"), self.msgHandler) + + + def msgHandler(self, msg): + try: + new_val = pmt.to_python(pmt.cdr(msg)) + + if type(new_val) == bool or type(new_val) == int: + if type(new_val) == bool: + super().setState(new_val) + else: + if new_val == 1: + super().setState(True) + else: + super().setState(False) + else: + gr.log.error("Value received was not an int or a bool: %s" % str(type(new_val))) + + except Exception as e: + gr.log.error("Error with message conversion: %s" % str(e)) + + def setState(self, on_off): + super().setState(on_off) + diff --git a/gr-qtgui/python/qtgui/levelgauge.py b/gr-qtgui/python/qtgui/levelgauge.py new file mode 100644 index 0000000000..cee33eb58d --- /dev/null +++ b/gr-qtgui/python/qtgui/levelgauge.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2020 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# + +from threading import Lock +import sys + +from PyQt5.QtWidgets import QFrame, QHBoxLayout, QVBoxLayout, QLabel, QProgressBar +from PyQt5.QtGui import QColor +from PyQt5.QtCore import Qt as Qtc +from PyQt5.QtCore import pyqtSignal +from PyQt5.QtGui import QPalette + +from gnuradio import gr +import pmt + +class LabeledLevelGauge(QFrame): + # Positions: 1 = above, 2=below, 3=left, 4=right + def __init__(self, lbl='', barColor='blue', backgroundColor='white', fontColor='black', + minValue=0, maxValue=100, maxSize=80, position=1, isVertical=True, + isFloat=False, scaleFactor=1, showValue=False, parent=None): + QFrame.__init__(self, parent) + self.numberControl = LevelGauge(barColor, backgroundColor, minValue, maxValue, + maxSize, isVertical, isFloat, scaleFactor, showValue, + parent) + + if position < 3: + layout = QVBoxLayout() + else: + layout = QHBoxLayout() + + self.lbl = lbl + self.showvalue = showValue + self.isFloat = isFloat + self.isVertical = isVertical + self.scaleFactor = scaleFactor + + self.lblcontrol = QLabel(lbl, self) + self.lblcontrol.setAlignment(Qtc.AlignCenter) + + # For whatever reason, the progressbar doesn't show the number in the bar if it's + # vertical, only if it's horizontal + if self.showvalue and (isFloat or self.isVertical): + textstr = self.buildTextStr(minValue/self.scaleFactor) + self.lblcontrol.setText(textstr) + + if fontColor != 'default': + self.lblcontrol.setStyleSheet("QLabel { color : " + fontColor + "; }") + + # add top or left + if len: + if position == 1 or position == 3: + layout.addWidget(self.lblcontrol) + else: + self.hasLabel = False + + layout.addWidget(self.numberControl) + + # Add bottom or right + if len: + if position == 2 or position == 4: + layout.addWidget(self.lblcontrol) + + layout.setAlignment(Qtc.AlignCenter | Qtc.AlignVCenter) + self.setLayout(layout) + + self.show() + + def buildTextStr(self, new_value): + textstr = "" + if len(self.lbl) > 0: + textstr = self.lbl + " - " + + if self.isFloat: + textstr += "%.2f" % (new_value) + else: + textstr += str(new_value) + + return textstr + + def valChanged(self, new_value): + if not self.showvalue: + return + + if self.isFloat or self.isVertical: + if self.lbl: + textstr = self.buildTextStr(new_value) + self.lblcontrol.setText(textstr) + + def setValue(self, new_value): + self.valChanged(new_value) + + if int(self.scaleFactor) != 1: + new_value = int(new_value * self.scaleFactor) + + self.numberControl.setValue(new_value) + +class LevelGauge(QProgressBar): + # Notifies to avoid thread conflicts on paints + updateInt = pyqtSignal(int) + updateFloat = pyqtSignal(float) + + def __init__(self, barColor='blue', backgroundColor='white', minValue=0, maxValue=100, + maxSize=80, isVertical=True, isFloat=False, scaleFactor=1, showValue=False, + parent=None): + super().__init__(parent) + + self.updateInt.connect(self.onUpdateInt) + self.updateFloat.connect(self.onUpdateFloat) + + self.lock = Lock() + + self.maxSize = maxSize + + p = super().palette() + + if backgroundColor != 'default': + p.setColor(QPalette.Base, QColor(backgroundColor)) + + if barColor != 'default': + p.setColor(QPalette.Highlight, QColor(barColor)) + + if backgroundColor != 'default' or barColor != 'default': + super().setPalette(p) + + if (not isFloat) and showValue: + super().setFormat("%v") # This shows the number in the bar itself. + super().setTextVisible(True) + else: + super().setTextVisible(False) + + super().setMinimum(minValue) + super().setMaximum(maxValue) + + if isVertical: + super().setOrientation(Qtc.Vertical) + else: + super().setOrientation(Qtc.Horizontal) + + def onUpdateInt(self, new_value): + if new_value > super().maximum(): + new_value = super().maximum() + elif new_value < super().minimum(): + new_value = super().minimum() + + self.lock.acquire() + super().setValue(new_value) + self.lock.release() + + def onUpdateFloat(self, new_value): + if new_value > super().maximum(): + new_value = super().maximum() + elif new_value < super().minimum(): + new_value = super().minimum() + + self.lock.acquire() + super().setValue(new_value) + self.lock.release() + + def setValue(self, new_value): + if type(new_value) == int: + self.updateInt.emit(new_value) + else: + self.updateFloat.emit(new_value) + +class GrLevelGauge(gr.sync_block, LabeledLevelGauge): + """ + This block creates a level gauge. The value can be set either + with a variable or an input message. + + NOTE: This control has some quirks due to the fact that + QProgressBar only accepts integers. If you want to work with + floats, you have to use the scaleFactor to map incoming values + to the specified min/max range. For instance if the min/max + are 0-100 but your incoming values are 0.0-1.0, you will need + to set a scalefactor of 100. + """ + def __init__(self, lbl='', barColor='blue', backgroundColor='white', fontColor='black', + minValue=0, maxValue=100, maxSize=80, isVertical=True, position=1, + isFloat=False, scaleFactor=1, showValue=False, parent=None): + gr.sync_block.__init__(self, name="LevelGauge", in_sig=None, out_sig=None) + LabeledLevelGauge.__init__(self, lbl, barColor, backgroundColor, fontColor, minValue, + maxValue, maxSize, position, isVertical, isFloat, + scaleFactor, showValue, parent) + self.lbl = lbl + + if minValue > maxValue: + gr.log.error("Min value is greater than max value.") + sys.exit(1) + + self.message_port_register_in(pmt.intern("value")) + self.set_msg_handler(pmt.intern("value"), self.msgHandler) + + def msgHandler(self, msg): + try: + new_val = pmt.to_python(pmt.cdr(msg)) + + if type(new_val) == float or type(new_val) == int: + super().setValue(new_val) + else: + gr.log.error("Value received was not an int or a float: %s" % str(type(new_val))) + + except Exception as e: + gr.log.error("Error with message conversion: %s" % str(e)) + + def setValue(self, new_value): + super().setValue(new_value) diff --git a/gr-qtgui/python/qtgui/msgcheckbox.py b/gr-qtgui/python/qtgui/msgcheckbox.py new file mode 100644 index 0000000000..8d2a52ae07 --- /dev/null +++ b/gr-qtgui/python/qtgui/msgcheckbox.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2020 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# + +from PyQt5 import Qt +from PyQt5.QtWidgets import QFrame, QVBoxLayout +from PyQt5.QtCore import Qt as Qtc + +from gnuradio import gr +import pmt + +class CheckBoxEx(Qt.QCheckBox): + def __init__(self, lbl, callback=None): + Qt.QCheckBox.__init__(self) + self.setText(lbl) + self.callback = callback + + self.stateChanged.connect(self.onToggleClicked) + + def onToggleClicked(self): + if self.callback is not None: + self.callback(super().isChecked()) + +class MsgCheckBox(gr.sync_block, QFrame): + """ + This block creates a variable checkbox. Leave the label blank to + use the variable id as the label. A checkbox selects between + two values of similar type, but will stay depressed until + clicked again. The variable will take on one value or the other + depending on whether the button is pressed or released. + + This control will also produce a state message matching the + set values. + """ + def __init__(self, callback, lbl, pressedReleasedDict, initPressed, alignment, + valignment, outputmsgname='value'): + gr.sync_block.__init__(self, name="MsgCheckBox", in_sig=None, out_sig=None) + QFrame.__init__(self) + + self.outputmsgname = outputmsgname + self.chkBox = CheckBoxEx(lbl, self.onToggleClicked) + + layout = QVBoxLayout() + + layout.addWidget(self.chkBox) + + if alignment == 1: + halign = Qtc.AlignCenter + elif alignment == 2: + halign = Qtc.AlignLeft + else: + halign = Qtc.AlignRight + + if valignment == 1: + valign = Qtc.AlignVCenter + elif valignment == 2: + valign = Qtc.AlignTop + else: + valign = Qtc.AlignBottom + + layout.setAlignment(halign | valign) + self.setLayout(layout) + + self.callback = callback + self.pressReleasedDict = pressedReleasedDict + + self.message_port_register_out(pmt.intern("state")) + + if initPressed: + self.chkctl.setChecked(True) + + self.show() + + def onToggleClicked(self, checked): + if self.chkBox.isChecked(): + self.callback(self.pressReleasedDict['Pressed']) + + if type(self.pressReleasedDict['Pressed']) == bool: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.from_bool(self.pressReleasedDict['Pressed']))) + elif type(self.pressReleasedDict['Pressed']) == int: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.from_long(self.pressReleasedDict['Pressed']))) + elif type(self.pressReleasedDict['Pressed']) == float: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.from_float(self.pressReleasedDict['Pressed']))) + else: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.intern(self.pressReleasedDict['Pressed']))) + else: + self.callback(self.pressReleasedDict['Released']) + + if type(self.pressReleasedDict['Released']) == bool: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.from_bool(self.pressReleasedDict['Released']))) + elif type(self.pressReleasedDict['Released']) == int: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.from_long(self.pressReleasedDict['Released']))) + elif type(self.pressReleasedDict['Released']) == float: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.from_float(self.pressReleasedDict['Released']))) + else: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.intern(self.pressReleasedDict['Released']))) diff --git a/gr-qtgui/python/qtgui/msgpushbutton.py b/gr-qtgui/python/qtgui/msgpushbutton.py new file mode 100644 index 0000000000..bee142cac7 --- /dev/null +++ b/gr-qtgui/python/qtgui/msgpushbutton.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2020 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# + +from PyQt5 import Qt +from gnuradio import gr +import pmt + +class MsgPushButton(gr.sync_block, Qt.QPushButton): + """ + This block creates a variable push button that creates a message + when clicked. Leave the label blank to use the variable id as + the label. You can define both the output message pmt name as + well as the value and value type. + """ + def __init__(self, lbl, msgName, msgValue, relBackColor, relFontColor): + gr.sync_block.__init__(self, name="MsgPushButton", in_sig=None, out_sig=None) + Qt.QPushButton.__init__(self, lbl) + + self.lbl = lbl + self.msgName = msgName + self.msgValue = msgValue + + styleStr = "" + if relBackColor != 'default': + styleStr = "background-color: " + relBackColor + "; " + + if relFontColor: + styleStr += "color: " + relFontColor + "; " + + self.setStyleSheet(styleStr) + + self.clicked[bool].connect(self.onBtnClicked) + + self.message_port_register_out(pmt.intern("pressed")) + + def onBtnClicked(self, pressed): + if type(self.msgValue) == int: + self.message_port_pub(pmt.intern("pressed"), + pmt.cons(pmt.intern(self.msgName), pmt.from_long(self.msgValue))) + elif type(self.msgValue) == float: + self.message_port_pub(pmt.intern("pressed"), + pmt.cons(pmt.intern(self.msgName), pmt.from_float(self.msgValue))) + elif type(self.msgValue) == str: + self.message_port_pub(pmt.intern("pressed"), + pmt.cons(pmt.intern(self.msgName), pmt.intern(self.msgValue))) + elif type(self.msgValue) == bool: + self.message_port_pub(pmt.intern("pressed"), + pmt.cons(pmt.intern(self.msgName), pmt.from_bool(self.msgValue))) diff --git a/gr-qtgui/python/qtgui/qa_qtgui.py b/gr-qtgui/python/qtgui/qa_qtgui.py index 5d1c98bef4..457b6f8c9f 100644 --- a/gr-qtgui/python/qtgui/qa_qtgui.py +++ b/gr-qtgui/python/qtgui/qa_qtgui.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Copyright 2011 Free Software Foundation, Inc. +# Copyright 2011, 2020 Free Software Foundation, Inc. # # This file is part of GNU Radio # diff --git a/gr-qtgui/python/qtgui/togglebutton.py b/gr-qtgui/python/qtgui/togglebutton.py new file mode 100644 index 0000000000..0c0cf187f6 --- /dev/null +++ b/gr-qtgui/python/qtgui/togglebutton.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2020 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# + + +from PyQt5 import Qt +from gnuradio import gr +import pmt + +class ToggleButton(gr.sync_block, Qt.QPushButton): + """ + This block creates a variable toggle button. Leave the label + blank to use the variable id as the label. A toggle button + selects between two values of similar type, but will stay + depressed until clicked again. The variable will take on one + value or the other depending on whether the button is pressed + or released. This button will also produce a state message + matching the set values. + """ + def __init__(self, callback, lbl, pressedReleasedDict, initPressed, outputmsgname='value'): + gr.sync_block.__init__(self, name="ToggleButton", in_sig=None, out_sig=None) + Qt.QPushButton.__init__(self, lbl) + self.setCheckable(True) + self.lbl = lbl + self.callback = callback + self.pressReleasedDict = pressedReleasedDict + + self.outputmsgname = outputmsgname + self.relBackColor = 'default' + self.relFontColor = 'default' + self.pressBackColor = 'default' + self.pressFontColor = 'default' + + self.message_port_register_out(pmt.intern("state")) + + if initPressed: + self.setChecked(True) + self.state = 1 + else: + self.state = 0 + + self.clicked[bool].connect(self.onToggleClicked) + + def setColors(self, relBackColor, relFontColor, pressBackColor, pressFontColor): + self.relBackColor = relBackColor + self.relFontColor = relFontColor + self.pressBackColor = pressBackColor + self.pressFontColor = pressFontColor + + self.setColor() + + def setColor(self): + if self.state: + styleStr = "" + if self.pressBackColor != 'default': + styleStr = "background-color: " + self.pressBackColor + "; " + + if self.pressFontColor: + styleStr += "color: " + self.pressFontColor + "; " + + self.setStyleSheet(styleStr) + else: + styleStr = "" + if self.relBackColor != 'default': + styleStr = "background-color: " + self.relBackColor + "; " + + if self.relFontColor: + styleStr += "color: " + self.relFontColor + "; " + + self.setStyleSheet(styleStr) + + def onToggleClicked(self, pressed): + if pressed: + self.state = 1 + self.callback(self.pressReleasedDict['Pressed']) + else: + self.state = 0 + self.callback(self.pressReleasedDict['Released']) + + self.setColor() + + if pressed: + if type(self.pressReleasedDict['Pressed']) == bool: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.from_bool(self.pressReleasedDict['Pressed']))) + elif type(self.pressReleasedDict['Pressed']) == int: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.from_long(self.pressReleasedDict['Pressed']))) + elif type(self.pressReleasedDict['Pressed']) == float: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.from_float(self.pressReleasedDict['Pressed']))) + else: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.intern(self.pressReleasedDict['Pressed']))) + else: + if type(self.pressReleasedDict['Released']) == bool: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.from_bool(self.pressReleasedDict['Released']))) + elif type(self.pressReleasedDict['Released']) == int: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.from_long(self.pressReleasedDict['Released']))) + elif type(self.pressReleasedDict['Released']) == float: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.from_float(self.pressReleasedDict['Released']))) + else: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.intern(self.pressReleasedDict['Released']))) diff --git a/gr-qtgui/python/qtgui/toggleswitch.py b/gr-qtgui/python/qtgui/toggleswitch.py new file mode 100644 index 0000000000..7ef5a113fa --- /dev/null +++ b/gr-qtgui/python/qtgui/toggleswitch.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright 2020 Free Software Foundation, Inc. +# +# This file is part of GNU Radio +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# + +from gnuradio import gr +import pmt + +from PyQt5.QtWidgets import QFrame, QHBoxLayout, QVBoxLayout, QLabel +from PyQt5.QtGui import QPainter, QBrush, QColor, QPen, QFontMetricsF +from PyQt5.QtCore import Qt as Qtc +from PyQt5.QtCore import QRect + +class LabeledToggleSwitch(QFrame): + # Positions: 1 = above, 2=below, 3=left, 4=right + def __init__(self, lbl='', onColor='green', offColor='red', initialState=False, + maxSize=50, position=1, parent=None, callback=None, alignment=1, valignment=1): + QFrame.__init__(self, parent) + self.numberControl = ToggleSwitch(onColor, offColor, initialState, maxSize, + parent, callback) + + if position < 3: + layout = QVBoxLayout() + else: + layout = QHBoxLayout() + + self.lbl = lbl + self.lblcontrol = QLabel(lbl, self) + + if position == 3: # left of switch + self.lblcontrol.setAlignment(Qtc.AlignRight) + elif position == 4: # right of switch + self.lblcontrol.setAlignment(Qtc.AlignLeft) + else: + # Above or below + self.lblcontrol.setAlignment(Qtc.AlignCenter) + + # add top or left + if len: + if position == 1 or position == 3: + layout.addWidget(self.lblcontrol) + + layout.addWidget(self.numberControl) + + # Add bottom or right + if len: + if position == 2 or position == 4: + layout.addWidget(self.lblcontrol) + + if alignment == 1: + halign = Qtc.AlignCenter + elif alignment == 2: + halign = Qtc.AlignLeft + else: + halign = Qtc.AlignRight + + if valignment == 1: + valign = Qtc.AlignVCenter + elif valignment == 2: + valign = Qtc.AlignTop + else: + valign = Qtc.AlignBottom + + layout.setAlignment(halign | valign) + + self.setLayout(layout) + + textfont = self.lblcontrol.font() + metrics = QFontMetricsF(textfont) + + maxWidth = max((maxSize+4), (maxSize*2 + metrics.width(lbl))) + maxHeight = max((maxSize/2+4), (maxSize/2 + metrics.height()+2)) + + self.setMinimumSize(int(maxWidth), int(maxHeight)) + + self.show() + + def setState(self, on_off): + self.numberControl.setState(on_off) + +class ToggleSwitch(QFrame): + def __init__(self, onColor='green', offColor='red', initialState=False, maxSize=50, + parent=None, callback=None): + QFrame.__init__(self, parent) + + self.maxSize = maxSize + self.curState = initialState + self.onColor = QColor(onColor) + self.offColor = QColor(offColor) + self.callback = callback + self.setMinimumSize(maxSize, maxSize/2) + self.setMaximumSize(maxSize, maxSize/2) + + def setState(self, on_off): + self.curState = on_off + if self.callback is not None: + self.callback(on_off) + + super().update() + + def paintEvent(self, event): + super().paintEvent(event) + + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + size = self.size() + brush = QBrush() + + center_x = size.width()/2 + + if self.curState: + brush.setColor(self.onColor) + painter.setPen(QPen(self.onColor, 0)) + else: + brush.setColor(self.offColor) + painter.setPen(QPen(self.offColor, 0)) + + brush.setStyle(Qtc.SolidPattern) + painter.setBrush(brush) + + # Draw the switch background + centerRect = QRect(size.width()/4, 0, size.width()/2-4, size.height()) + painter.drawRect(centerRect) + painter.drawEllipse(0, 0, size.height(), size.height()) + painter.drawEllipse(size.width()/2, 0, size.height(), size.height()) + + # Draw the switch itself + brush.setColor(QColor('white')) + painter.setBrush(brush) + painter.setPen(QPen(QColor('white'), 0)) + if self.curState: + painter.drawEllipse(2, 2, size.height() - 4, size.height() - 4) + else: + painter.drawEllipse(center_x+2, 2, size.height() - 4, size.height() - 4) + + def mousePressEvent(self, event): + if event.x() <= self.size().width()/2: + self.setState(True) + else: + self.setState(False) + + super().update() + +class GrToggleSwitch(gr.sync_block, LabeledToggleSwitch): + """ + This block creates a modern toggle switch. The variable will take + on one value or the other as set in the dialog. This button will + also produce a state message matching the set values. + """ + def __init__(self, callback, lbl, pressedReleasedDict, initialState=False, + onColor='green', offColor='silver', position=3, maxSize=50, + alignment=1, valignment=1, parent=None, outputmsgname='value'): + gr.sync_block.__init__(self, name="ToggleSwitch", in_sig=None, out_sig=None) + LabeledToggleSwitch.__init__(self, lbl, onColor, offColor, initialState, + maxSize, position, parent, self.notifyUpdate, + alignment, valignment) + + self.outputmsgname = outputmsgname + self.pressReleasedDict = pressedReleasedDict + self.callback = callback + self.message_port_register_out(pmt.intern("state")) + + def notifyUpdate(self, new_val): + if self.callback is not None: + if new_val: + self.callback(self.pressReleasedDict['Pressed']) + else: + self.callback(self.pressReleasedDict['Released']) + + if new_val: + if type(self.pressReleasedDict['Pressed']) == bool: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.from_bool(self.pressReleasedDict['Pressed']))) + elif type(self.pressReleasedDict['Pressed']) == int: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.from_long(self.pressReleasedDict['Pressed']))) + elif type(self.pressReleasedDict['Pressed']) == float: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.from_float(self.pressReleasedDict['Pressed']))) + else: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.intern(self.pressReleasedDict['Pressed']))) + else: + if type(self.pressReleasedDict['Released']) == bool: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.from_bool(self.pressReleasedDict['Released']))) + elif type(self.pressReleasedDict['Released']) == int: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.from_long(self.pressReleasedDict['Released']))) + elif type(self.pressReleasedDict['Released']) == float: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.from_float(self.pressReleasedDict['Released']))) + else: + self.message_port_pub(pmt.intern("state"), + pmt.cons(pmt.intern(self.outputmsgname), + pmt.intern(self.pressReleasedDict['Released']))) |