"""
Show all of the Moku:AF statistics in real-time.
"""

# To do:
# - Add robustness to comms errors.
# - Is it possible to generate the tooltip on demand, rather than updating when a value is added?
# - Input board:
# - Output board:
# - Digital board:
# - Other statistics?
# - Add warning and error conditions for values that need it.
# - Allow the warning and error thresholds to be configured from the GUI?
# - Show running processes.


from collections import defaultdict
from copy import copy
import csv
from datetime import datetime
import math
import time
from pymoku.moku import Moku
from PySide6.QtCore import QMargins, QPoint, QRect, QSize, Qt, QTimer
from PySide6.QtGui import QAction, QBrush, QColor, QMouseEvent, QPainter, QPen
from PySide6.QtWidgets import QCheckBox, QFileDialog, QGridLayout, QGroupBox, QHBoxLayout
from PySide6.QtWidgets import QInputDialog, QLabel, QMenu, QToolTip, QSizePolicy
from PySide6.QtWidgets import QSpacerItem, QToolButton, QVBoxLayout, QWidget
from typing import Callable, Optional
from . import settings


# How to format different types of measurements.
_FORMAT = {
    'voltage': '{:.3f}',
    'voltages': '{:.3f}',
    'current': '{:.3f}',
    'currents': '{:.3f}',
    'power': '{:.3f}',
    'PWM': '{:d}',
    'RPM': '{:d}',
    'temperature': '{:.1f}'
}

# What units to use with different types of measurements.
_UNITS = {
    'voltage': ' V',
    'voltages': ' V',
    'current': ' A',
    'currents': ' A',
    'power': ' W',
    'PWM': ' %',
    'RPM': '',
    'temperature': ' °C',
    'Temperature': ' °C'
}

_POLL_PERIOD_SETTING = 'Statistics/poll_period_ms'

_LAYOUT_MARGINS = QMargins(4, 4, 4, 4)  # left, top, right, bottom
_LAYOUT_SPACING = 4


class Sparkline(QWidget):

    """
    This widgets shows a numerical value, with units, along with a sparkline graph.

    The sparkline graph is shown behind the numerical value.

    Optional error and warning conditions can be configured. Numerical values
    that are in error are shown in red, warning values are shown in orange.

    Args:
        parent: The parent widget of this widget.
        name:   The name of this field in the CSV log file.
        format: The format string to use to print the value.
        units:  The units to show after the value. If this contains '?' then
                standard SI unit prefixes of 'k', 'M' or 'G' are added and the
                value scaled accordingly to reduce the size of the number shown.
                In this case the given format string is ignored.
        width:  The width hint of the widget.
        height: The height hint of the widget.
        is_err: If this is not None then it is a function that is called with
                the value to test if it is in error.
        is_warn: If this is not None then it is a function that is called with
                 the value to test if a warbing condition needs to be shown.
                 This function is not called if the value is in error.
    """
    def __init__(self,
                 parent: "StatisticsDialog",
                 name: str,
                 format: str,
                 units: str,
                 width=55,
                 height=30,
                 is_err: Optional[Callable[[float], bool]] = None,
                 is_warn: Optional[Callable[[float], bool]] = None):
        super().__init__(parent)
        self._statistics_dialog = parent
        self._name = name
        self._format = format
        self._units = units
        self._width = width
        self._height = height
        self._is_err = is_err
        self._is_warn = is_warn

        # Ensure that every Sparkline has a unique name
        assert name not in parent.sparkline_fieldnames, f'name = "{name}"'
        parent.sparkline_fieldnames.add(name)

        # If this None then _is_err() is called to decide if the value is in error.
        # Otherwise the value of this indicates if the value is in error.
        self._force_err = None

        self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
        self._values: list[float] = []  # values to plot, newest first

        self._background_color = QColor(255, 255, 255)

        self._graph_line_pen = QPen()
        self._graph_line_pen.setColor(QColor(190, 170, 205))
        self._graph_line_pen.setWidth(1)
        self._graph_line_pen.setCapStyle(Qt.RoundCap)
        self._graph_line_pen.setJoinStyle(Qt.RoundJoin)

        self._graph_fill_brush = QBrush()
        self._graph_fill_brush.setColor(QColor(230, 220, 235))
        self._graph_fill_brush.setStyle(Qt.SolidPattern)

        self._ok_text_pen = QPen(QColor('black'))
        self._warn_text_pen = QPen(QColor('darkorange'))
        self._err_text_pen = QPen(QColor('red'))
        self._axis_pen = QPen(QColor(32, 32, 32))

        # These are set in the first call to paintEvent(), when the painter object is known.
        self._value_font = None
        self._axis_font = None

        self._base_tooltip = ''

    def add(self, value: float, force_err: Optional[bool] = None) -> None:
        """
        Add a value to the sparkline.

        Args:
            value: The value to add.
            force_err: If this is not None then this forces whether the value
                       is shown as an error or not.
        """
        self._statistics_dialog.sparkline_latest_values[self._name] = value
        self._values.insert(0, value)
        self._force_err = force_err
        self.update()

    def sizeHint(self):
        return QSize(self._width, self._height)

    def setToolTip(self, tooltip: str) -> None:
        super().setToolTip(tooltip)
        self._base_tooltip = tooltip

    def paintEvent(self, e):
        painter = QPainter(self)
        box_width = painter.device().width()
        box_height = painter.device().height()
        rect = QRect(0, 0, box_width, box_height)

        if self._value_font is None:
            self._value_font = painter.font()
            self._axis_font = copy(self._value_font)
            self._axis_font.setPointSize(self._value_font.pointSize() * 0.6)

        # Fill the background of the graph.
        painter.fillRect(rect, self._background_color)

        self._plot_values(painter, rect)

        # The text string showing the current temperature.
        if self._values:
            value = self._values[0]
            text = self._str(value)
            if self._force_err is not None:
                if self._force_err:
                    pen = self._err_text_pen
                else:
                    pen = self._ok_text_pen
            elif self._is_err and self._is_err(value):
                pen = self._err_text_pen
            elif self._is_warn and self._is_warn(value):
                pen = self._warn_text_pen
            else:
                pen = self._ok_text_pen
            painter.setPen(pen)
            painter.setFont(self._value_font)
            painter.drawText(rect, Qt.AlignCenter, text)

        painter.end()

    def _plot_values(self, painter: QPainter, rect: QRect) -> None:
        """
        Draw a graph of the previous values.

        The graph is scaled vertically to fill the space. The most recent value
        is drawn at the right-hand edge of the widget.
        """
        if not self._values:
            return  # nothing to plot

        graph_width = rect.width()
        graph_height = rect.height()

        # Remove old values that are no longer shown.
        del self._values[graph_width:]

        min_value = min(self._values)
        max_value = max(self._values)
        temp_range = max_value - min_value
        if temp_range == 0:
            temp_range = 1

        # Calculate the position of each value on the graph.
        line_points = []
        for i, value in enumerate(self._values):
            line_points.append(QPoint(graph_width - i,
                                      graph_height - (value - min_value) / temp_range * graph_height))

        # Add some points to complete the polygon to fill under the graph.
        fill_points = copy(line_points)
        fill_points.append(QPoint(fill_points[-1].x(), graph_height))  # below the left-most point on the bottom axis
        fill_points.append(QPoint(graph_width, graph_height))          # below the right-most point on the bottom axis

        painter.setRenderHint(QPainter.Antialiasing)
        painter.setPen(Qt.NoPen)
        painter.setBrush(self._graph_fill_brush)
        painter.drawPolygon(fill_points)

        painter.setPen(self._graph_line_pen)
        painter.drawPolyline(line_points)

        tooltip = ''
        if self._base_tooltip:
            tooltip = self._base_tooltip + '\n'
        min_value_str = self._str(min_value)
        max_value_str = self._str(max_value)

        if self._statistics_dialog.sparkline_show_vertical_axis_range:
            # Show the range of the vertical axis on the graph.
            painter.setPen(self._axis_pen)
            painter.setFont(self._axis_font)
            painter.drawText(rect, Qt.AlignLeft | Qt.AlignBottom, self._str(min_value, False))
            painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, self._str(max_value, False))
        else:
            # Show the range of the vertical axis in the tooltip.
            tooltip += f'Showing values in the range {min_value_str} to {max_value_str}.'

        super().setToolTip(tooltip)

    def _str(self, value: float, show_units=True) -> str:
        """
        Return the string containing the value and its units.

        If the units contains '?' then the 'K' or 'M' units are used and the
        value scaled accordingly so that the display value is small.
        """
        if '?' in self._units:
            if value < 1e3:
                units = self._units.replace('?', '')
            elif value < 1e6:
                value /= 1e3
                units = self._units.replace('?', 'k')
            elif value < 1e9:
                value /= 1e6
                units = self._units.replace('?', 'M')
            else:
                value /= 1e9
                units = self._units.replace('?', 'G')

            if value < 10:
                fmt = '{:.1f}'
            else:
                fmt = '{:.0f}'

            res = fmt.format(value)
            if show_units:
                res += units
        else:
            res = self._format.format(value)
            if show_units:
                res += self._units

        return res


class SatellitePlotter(QWidget):
    """
    Plot the positions of the satellites being tracked by the GNSS module.
    """
    def __init__(self, parent):
        super().__init__(parent)
        self._enabled_background = QColor(0, 0, 0)
        self._disabled_background = QColor(128, 128, 128)
        self._enabled_axis_color = QColor(192, 192, 192)
        self._disabled_axis_color = QColor(64, 64, 64)
        self._satellite_size = 5
        self._satellites = []

        self.setMouseTracking(True)
        self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)

        # Satellite colors, indexed by the gnss_id field.
        self._color = (
            QColor('red'),
            QColor('orange'),
            QColor('yellow'),
            QColor('cyan'),
            QColor('green'),
            QColor('blue'),
            QColor('indigo'),
            QColor('violet'),
        )

    def update(self, satellites):
        self._satellites = satellites
        super().update()

    def paintEvent(self, e):
        painter = QPainter(self)
        box_width = painter.device().width()
        box_height = painter.device().height()
        middle_x = box_width / 2
        middle_y = box_height / 2
        radius = min(middle_x, middle_y) - self._satellite_size // 2

        painter.fillRect(QRect(0, 0, box_width, box_height),
                         self._enabled_background if self.isEnabled() else self._disabled_background)

        # Draw the X and Y axis.
        painter.setPen(self._enabled_axis_color if self.isEnabled() else self._disabled_axis_color)
        painter.drawLine(middle_x, middle_y - radius, middle_x, middle_y + radius)
        painter.drawLine(middle_x - radius, middle_y, middle_x + radius, middle_y)

        # Plot the position of the satellites.
        # The drawing window has the origin in the top-left corner.
        for sat in self._satellites:
            # Convert from degrees to radians
            azim = math.pi * sat['azim'] / 180
            elev = math.pi * sat['elev'] / 180

            # Convert to x,y.
            # Azimuth: 0° is north and increases to the east
            # Elevation: Angle above the horizon, 0° on horizon, 90° above
            x = math.sin(azim) * math.cos(elev) * radius + middle_x - self._satellite_size // 2
            y = box_height - (math.cos(azim) * math.cos(elev) * radius + middle_y - self._satellite_size // 2)

            # Draw the square.
            rect = QRect(x, y, self._satellite_size, self._satellite_size)
            if sat['sv_used']:
                painter.fillRect(rect, self._color[sat['gnss_id']])
            else:
                painter.setPen(self._color[sat['gnss_id']])
                painter.drawRect(rect)
            sat['rect'] = rect

        painter.end()

    def mouseMoveEvent(self, e: QMouseEvent):
        """
        Show a popup with the details of a satellite if the mouse is moved over
        a satellite.
        """
        for sat in self._satellites:
            if 'rect' in sat and sat['rect'].contains(e.pos()):
                QToolTip.showText(e.globalPos(), (f"{sat['gnss_id_str']} {sat['sv_id']}\n"
                                                  f"Quality: {sat['quality_ind']} / 7\n"
                                                  f"Used: {sat['sv_used']}\n"
                                                  f"Azimuth: {sat['azim']}°\n"
                                                  f"Elevation: {sat['elev']}°"))
                return
        QToolTip.hideText()


class GroupBoxWithMenu(QGroupBox):
    """
    Extend the QGroupBox by adding a hamburger menu button at the right of the
    group box's title.

    The purpose of this class is to "sneak" a hamburger menu into the top-right
    of the Statistics dialog window. This avoids losing lots of space to a
    menu bar.
    """

    def __init__(self, parent: 'StatisticsDialog', title: str):
        super().__init__(parent=parent, title=title)

        self._menu = QMenu()
        self._menu.addAction('Poll period', parent.set_poll_period)

        self._show_vertical_axis_action = QAction('Show vertical axis', parent)
        self._show_vertical_axis_action.setCheckable(True)
        self._show_vertical_axis_action.setChecked(parent.sparkline_show_vertical_axis_range)
        self._show_vertical_axis_action.triggered.connect(parent.toggle_vertical_axis)
        self._menu.addAction(self._show_vertical_axis_action)

        GroupBoxWithMenu._log_to_csv_file_action = QAction('Log to CSV file', parent)
        GroupBoxWithMenu._log_to_csv_file_action.setCheckable(True)
        GroupBoxWithMenu._log_to_csv_file_action.setChecked(False)
        GroupBoxWithMenu._log_to_csv_file_action.triggered.connect(parent.log_to_csv_file)
        self._menu.addAction(GroupBoxWithMenu._log_to_csv_file_action)

        label = '☰'
        self._button = QToolButton(parent=self, text=label)
        self._button.setToolTip('Change statistics settings')
        self._button.clicked.connect(self._on_clicked)

        # Make the button the size of the label plus a margin.
        width = self.fontMetrics().horizontalAdvance(label)
        height = self.fontMetrics().height()
        self._button.setMaximumSize(width + 6, height + 2)

    def resizeEvent(self, e):
        """
        Override the default resizeEvent to set position of the hamburger button
        now that the size of the group box is known.
        """
        super().resizeEvent(e)
        button_pos = self.size()
        button_pos -= self._button.size()
        self._button.move(button_pos.width(), -1)

    def _on_clicked(self):
        """
        Show the popup menu when the button is clicked.
        """
        pos = self.mapToGlobal(self._button.pos())
        pos += QPoint(0, self._button.size().height())
        self._menu.popup(pos)

    def setEnabled(self, state: bool):
        """
        Ensure that the button is always enabled even if the group is disabled.

        Can't just call super().setEnabled(state) because this sets the state
        of the menu button.
        """
        def set_enabled(widget):
            for child in widget.children():
                set_enabled(child)
            widget.setEnabled(state)

        set_enabled(self.layout())

        if not state:
            self._button.setEnabled(True)
            self._menu.setEnabled(True)


class StatisticsDialog(QWidget):
    """
    The dialog window that shows all of the statistics from the Moku.
    """

    def __init__(self, moku: Moku):
        super().__init__()
        self.setWindowTitle(f'{moku.name} statistics')

        self._moku = moku
        self._prev_update_time = 0
        self._gnss_plotter: SatellitePlotter

        # These fields manage information about all of the Sparkline objects
        # in this dialog.

        # The names of the values that are logged using Sparkline objects.
        self.sparkline_fieldnames = set()

        # Map field names to their value in the current update cycle.
        self.sparkline_latest_values = {}

        # If True the minimum and maximum values on the vertical axis are shown.
        self.sparkline_show_vertical_axis_range = True

        self.layout = QHBoxLayout(self)
        self.layout.setContentsMargins(_LAYOUT_MARGINS)
        self.layout.setSpacing(_LAYOUT_SPACING)

        # The stretch factors are based on the number of Sparklines in each.
        self.layout.addLayout(self._add_column1(), stretch=2)
        self.layout.addLayout(self._add_column2(), stretch=2)

        if moku.DEV_NAME == 'mokudelta':
            self.layout.addWidget(self._add_digital_board(), stretch=4)
            self.layout.addLayout(self._add_carrier_board_and_gnss_module(), stretch=4)

            # The next two columns show stats for each of the input, RF and output boards.
            for board_idx in range(2):
                column_layout = QVBoxLayout()
                column_layout.addWidget(self._add_input_board(board_idx))
                column_layout.addWidget(self._add_balun_board(board_idx))
                column_layout.addWidget(self._add_output_board(board_idx))
                column_layout.addStretch()
                self.layout.addLayout(column_layout, stretch=4)

        if moku.DEV_NAME == 'mokupro':
            self.layout.addWidget(self._add_mokupro_voltages(), stretch=1)

        if moku.DEV_NAME in ('moku20', 'mokupro'):
            self.layout.addWidget(self._add_temperatures(), stretch=1)

        # The opened file to log CSV data into.
        self._csv_file = None

        self._timer = QTimer(self)
        self._timer.timeout.connect(self._update)
        self._poll_period_ms = int(settings.value(_POLL_PERIOD_SETTING, 2000))
        self._timer.start(self._poll_period_ms)

        # Add a single shot timer to happen very soon to show the initial value
        # of the temperatures.
        QTimer.singleShot(10, self._update)

    def set_poll_period(self):
        """
        Show a dialog box to allow the user to change the poll period.
        """
        poll_period_secs, is_ok = QInputDialog.getInt(self,
                                                      'Poll period',
                                                      'Enter the poll period (in seconds):',
                                                      self._poll_period_ms // 1000,
                                                      1)
        if is_ok:
            self._poll_period_ms = poll_period_secs * 1000
            self._timer.stop()
            self._timer.start(self._poll_period_ms)
            settings.setValue(_POLL_PERIOD_SETTING, self._poll_period_ms)

    def toggle_vertical_axis(self, is_checked: bool):
        self.sparkline_show_vertical_axis_range = is_checked
        self.update()

    def log_to_csv_file(self, is_checked: bool):
        """
        This is called when the 'Log to CSV file' menu is selected.

        If is_checked is True then prompt the user for the file to log to and
        start logging.

        If is_checked is false then stop logging to the file.
        """
        if is_checked:
            filename = QFileDialog.getSaveFileName(self, 'Log to CSV file', '', 'CSV files (*.csv)')[0]
            if filename:
                self._csv_file = open(filename, 'w')
                self._csv_writer = None  # will be created when first row is written
            else:
                # The user has cancelled the dialog. Need to uncheck the
                # 'Log to CSV file' menu.
                GroupBoxWithMenu._log_to_csv_file_action.setChecked(False)
        else:
            # Stop logging to this file.
            self._csv_file.close()
            self._csv_file = None

    def closeEvent(self, e):
        self._timer.stop()
        super().closeEvent(e)

    def showEvent(self, e):
        self._timer.start(self._poll_period_ms)
        super().showEvent(e)

    def _update(self):
        """
        Read the latest statistics from the Moku:AF via the API and update the
        the GUI with the new values.
        """
        if (stats := self._moku.get_statistics()) is None:
            return

        self.sparkline_latest_values = dict(time=datetime.now().strftime('%Y-%m-%d %H:%M:%S'))

        self._time_since_last_update = time.time() - self._prev_update_time
        self._update_column1(stats)
        self._update_column2(stats)

        if self._moku.DEV_NAME == 'mokudelta':
            self._update_digital_board(stats['digital_board'])
            self._update_carrier_board(stats.get('carrier_board', None))
            self._update_gnss_module(stats.get('gnss', None))
            for board_idx in range(2):
                self._update_input_board(board_idx, stats['input_boards'][board_idx])
                if 'balun_boards' in stats:
                    self._update_balun_board(board_idx, stats['balun_boards'][board_idx])
                else:
                    self._update_balun_board(board_idx, None)
                self._update_output_board(board_idx, stats['output_boards'][board_idx])

        if self._moku.DEV_NAME == 'mokupro':
            self._update_mokupro_voltages(stats['voltages'])

        if self._moku.DEV_NAME in ('moku20', 'mokupro'):
            self._update_temperatures(stats['temperatures'])

        self._prev_update_time = time.time()

        if self._csv_file:
            if not self._csv_writer:
                # Sort the fieldnames case insensitively, with the time first.
                fieldnames = ['time'] + sorted(self.sparkline_fieldnames, key=str.casefold)
                self._csv_writer = csv.DictWriter(self._csv_file, fieldnames=fieldnames)
                self._csv_writer.writeheader()

            self._csv_writer.writerow(self.sparkline_latest_values)

    def _add_column1(self) -> QVBoxLayout:
        """
        Add the group boxes that go into the first column.
        """
        layout = QVBoxLayout()
        # The stretch factors are based on the number of Sparklines in each.
        layout.addWidget(self._add_cpu_times(), stretch=4)
        layout.addWidget(self._add_load_average(), stretch=3)
        layout.addWidget(self._add_disk_usage(), stretch=3)
        layout.addWidget(self._add_disk_io(), stretch=2)
        if self._moku.DEV_NAME in ('mokupro', 'mokudelta'):
            layout.addWidget(self._add_network(), stretch=2)
        layout.addItem(QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding))
        return layout

    def _update_column1(self, stats):
        self._update_cpu_times(stats['cpu_times'])
        self._update_load_average(stats['load_average'])
        self._update_disk_usage(stats['disk_usage'])
        self._update_disk_io(stats['disk_io'])
        if self._moku.DEV_NAME in ('mokupro', 'mokudelta'):
            self._update_network(stats['network'])

    def _add_column2(self) -> QVBoxLayout:
        """
        Add the group boxes that go into the second column.
        """
        layout = QVBoxLayout()
        # The stretch factors are based on the number of Sparklines in each.
        layout.addWidget(self._add_memory(), stretch=5)
        if self._moku.DEV_NAME in ('mokupro', 'mokudelta'):
            layout.addWidget(self._add_ddr(), stretch=12)
        else:
            layout.addWidget(self._add_network(), stretch=2)
        layout.addWidget(self._add_i2c_utilization(), stretch=1)
        layout.addItem(QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.MinimumExpanding))
        return layout

    def _update_column2(self, stats):
        self._update_memory(stats['memory'])
        if self._moku.DEV_NAME in ('mokupro', 'mokudelta'):
            self._update_ddr(stats['ddr_controller'])
        else:
            self._update_network(stats['network'])
        if 'i2c_utilization' in stats:
            self._update_i2c_utilization(stats['i2c_utilization'])

    _LINUX_CPU_TIMES_LABELS = ('user', 'system', 'iowait', 'idle')

    def _add_cpu_times(self) -> QGroupBox:
        """
        Add the group showing all of the linux CPU times.
        """
        group = QGroupBox(self, title='CPU time')
        layout = QGridLayout()
        self._linux_cpu_times = {}
        self._prev_cpu_times = None
        for row, name in enumerate(self._LINUX_CPU_TIMES_LABELS):
            layout.addWidget(QLabel(name), row, 0)
            widget = Sparkline(self, f'CPU time {name}', '{:.1f}', '%')
            self._linux_cpu_times[name] = widget
            layout.addWidget(widget, row, 1)
        group.setLayout(layout)
        return group

    def _update_cpu_times(self, stats: dict[str, float]) -> None:
        """
        The stats are the cumulative time, in seconds, in each state.
        Convert this to a percentage.
        """
        if self._prev_cpu_times is not None:
            total = 0.0
            deltas = {}
            for name in self._LINUX_CPU_TIMES_LABELS:
                delta = stats[name] - self._prev_cpu_times[name]
                total += delta
                deltas[name] = delta
            for name in self._LINUX_CPU_TIMES_LABELS:
                self._linux_cpu_times[name].add(100 * deltas[name] / total)
        self._prev_cpu_times = copy(stats)

    def _add_load_average(self) -> QGroupBox:
        """
        Add the group showing the linux load average.
        """
        group = QGroupBox(self, title='Load average')
        layout = QGridLayout()
        self._load_average = []
        for row, name in enumerate(('1 min', '5 min', '15 min')):
            layout.addWidget(QLabel(name), row, 0)
            widget = Sparkline(self, f'load avg {name}', '{:.2f}', '')
            self._load_average.append(widget)
            layout.addWidget(widget, row, 1)
        group.setLayout(layout)
        return group

    def _update_load_average(self, stats: list[float]) -> None:
        for widget, value in zip(self._load_average, stats):
            widget.add(value)

    def _add_disk_usage(self) -> QGroupBox:
        """
        Add the group showing the disk usage of all linux filesystems.
        """
        group = QGroupBox(self, title='Mounts')
        self._disk_usage_layout = QGridLayout()
        group.setLayout(self._disk_usage_layout)
        return group

    def _update_disk_usage(self, stats: dict[str, dict[str, float]]) -> None:
        # Add the widgets to display the stats the first time, which is when
        # the names of the mount points are known.
        if (layout := self._disk_usage_layout).count() == 0:
            self._disk_usage_widgets = {}
            for row, name in enumerate(stats.keys()):
                layout.addWidget(QLabel(name), row, 0)
                widget = Sparkline(self, f'Mounts {name}', '{:.1f}', '%', is_err=lambda v: v >= 98, is_warn=lambda v: v >= 90)
                self._disk_usage_widgets[name] = widget
                layout.addWidget(widget, row, 1)

        for name, widget in self._disk_usage_widgets.items():
            widget.add(stats[name]['percent'])

    _DISK_IO_FIELDS = ('bytes_read', 'bytes_written')

    def _add_disk_io(self) -> QGroupBox:
        """
        Add the group showing the disk I/O of all linux filesystems.

        This involves repeating this grid layout for each disk:
        +-------+-------------+
        | Disk name           |
        +-------+-------------+
        | Read  | <rate> MB/s |
        +-------+-------------+
        | Write | <rate> MB/s |
        +-------+-------------+
        """
        group = QGroupBox(self, title='Disk I/O')
        layout = QGridLayout()
        self._prev_disk_io_stats = None
        self._disk_io = defaultdict(dict)
        for disk_idx, disk_name in enumerate(('mmcblk0', )):
            row = disk_idx * 5
            layout.addWidget(QLabel(disk_name), row, 0, 1, 2)
            layout.addWidget(QLabel('Read'), row + 1, 0)
            layout.addWidget(QLabel('Write'), row + 2, 0)

            for i, field_name in enumerate(self._DISK_IO_FIELDS, start=1):
                widget = Sparkline(self, f'Disk I/O {disk_name} {field_name}', '{:.1f}', ' MB/s')
                self._disk_io[disk_name][field_name] = widget
                layout.addWidget(widget, row + i, 1)

        group.setLayout(layout)
        return group

    def _update_disk_io(self, stats: dict[str, dict[str, int]]) -> None:
        if self._prev_disk_io_stats is not None:
            for disk_name, widgets in self._disk_io.items():
                for field_name in self._DISK_IO_FIELDS:
                    delta = stats[disk_name][field_name] - self._prev_disk_io_stats[disk_name][field_name]
                    widgets[field_name].add(round(delta / 1e6 / self._time_since_last_update, 1))
        self._prev_disk_io_stats = copy(stats)

    def _add_memory(self) -> QGroupBox:
        """
        Add the group showing the memory usage in linux.
        """
        if self._moku.DEV_NAME == 'mokugo':
            # On Moku:Go this is the top-right group box, so add the menu to it.
            group_class = GroupBoxWithMenu
        else:
            group_class = QGroupBox
        group = group_class(self, title='RAM usage')
        layout = QGridLayout()
        self._memory: dict[str, Sparkline] = {}
        for row, name in enumerate(('total', 'free', 'available', 'buffers', 'cached')):
            layout.addWidget(QLabel(name), row, 0)
            widget = Sparkline(self, f'RAM {name}', '{:.1f}', ' MB')
            self._memory[name] = widget
            layout.addWidget(widget, row, 1)
        group.setLayout(layout)
        return group

    def _update_memory(self, stats: dict[str, int]) -> None:
        for name, widget in self._memory.items():
            widget.add(round(stats[name] / 1e6, 1))

    _NETWORK_KEYS = ('bytes_recv', 'bytes_sent')
    _NETWORK_LABELS = ('RX', 'TX')

    def _add_network(self) -> QGroupBox:
        """
        Add the group showing the network usage in linux.

        """
        group = QGroupBox(self, title='Network')
        self._network_layout = QGridLayout()
        self._prev_network_stats = None
        self._network = defaultdict(dict)
        group.setLayout(self._network_layout)
        return group

    def _update_network(self, stats: dict[str, dict[str, int]]) -> None:
        # Add the widgets to display the stats the first time, which is when
        # the names of the network interaces are known.
        #
        # This involves repeating this grid layout for each network interface:
        #    +---------+------+------+
        #    |         | 'RX' | rate |
        #    | IF name +------+------+
        #    |         | 'TX' | rate |
        #    +---------+------+------+
        if (layout := self._network_layout).count() == 0:
            for if_idx, if_name in enumerate(stats.keys()):
                row = 2 * if_idx
                layout.addWidget(QLabel(if_name), row, 0, 2, 1)
                for key_idx, key in enumerate(self._NETWORK_KEYS):
                    layout.addWidget(QLabel(self._NETWORK_LABELS[key_idx]), row + key_idx, 1)
                    widget = Sparkline(self, f'{if_name} {key}', '', ' ?b/s')
                    self._network[if_name][key] = widget
                    layout.addWidget(widget, row + key_idx, 2)

        if self._prev_network_stats is not None:
            for if_name in stats.keys():
                for key in self._NETWORK_KEYS:
                    delta = stats[if_name][key] - self._prev_network_stats[if_name][key]
                    self._network[if_name][key].add(8 * delta / self._time_since_last_update)  # *8 to convert bytes to bits

        self._prev_network_stats = copy(stats)

    _NUM_DDR_METRICS = 5
    _DDR_TOOLTIPS = ('Cache M2',
                     'Cache M1',
                     'PL S_AXI_HP0_FPD',
                     'PL S_AXI_HP1_FPD and S_AXI_HP2_FPD',
                     'PL S_AXI_HP3_FPD',
                     'Total DDR activity')

    def _add_ddr(self) -> QGroupBox:
        """
        Add the group showing the DDR statistics.

        This involves repeating this grid layout for each DDR slot (and the total):
        +------------+------+------+
        |            | 'RX' | rate |
        | Slot label +------+------+
        |            | 'TX' | rate |
        +------------+------+------+
        """
        group = QGroupBox(self, title='DDR memory controller')
        layout = QGridLayout()
        self._prev_ddr_stats = None
        self._ddr = []
        labels = [f'APM S{i + 1}' for i in range(self._NUM_DDR_METRICS)] + ['Total']
        for label_idx, label_name in enumerate(labels):
            row = 2 * label_idx
            layout.addWidget(QLabel(label_name), row, 0, 2, 1)
            for i, dir in enumerate(('RX', 'TX')):
                layout.addWidget(QLabel(dir), row + i, 1)
                widget = Sparkline(self, f'DDR {label_name} {dir}', '{:.1f}', ' MB/s', width=100)
                widget.setToolTip('AXI performance monitor\n' + self._DDR_TOOLTIPS[label_idx])
                self._ddr.append(widget)
                layout.addWidget(widget, row + i, 2)

        group.setLayout(layout)
        return group

    def _update_ddr(self, stats: list[int]) -> None:
        if self._prev_ddr_stats is not None:
            # Compute the rate of change of each value and also the total RX and TX change.
            totals = [0, 0]
            for i, value in enumerate(stats):
                # Allow for the 32-bit stats from axipmon wrapping
                if value < self._prev_ddr_stats[i]:
                    delta = 2 ** 32 + value - self._prev_ddr_stats[i]
                else:
                    delta = value - self._prev_ddr_stats[i]
                totals[i % 2] += delta
                self._ddr[i].add(round(delta / self._time_since_last_update / 1e6, 1))

            if stats:
                # Calculate and display the total RX and TX rates.
                for i, total in enumerate(totals, start=2 * self._NUM_DDR_METRICS):
                    self._ddr[i].add(total / self._time_since_last_update / 1e6)

        self._prev_ddr_stats = copy(stats)

    def _add_i2c_utilization(self) -> QGroupBox:
        """
        Add a group for the I2C bus utilization.
        """
        group = QGroupBox(self, title='I2C bus utilization')
        self._i2c_utilization_layout = QHBoxLayout()
        group.setLayout(self._i2c_utilization_layout)
        self._i2c_utilization_widgets = {}
        self._i2c_prev_busy = {}
        self._i2c_prev_elapsed = {}
        return group

    def _update_i2c_utilization(self, stats: dict[str, dict[str, int]]) -> None:
        for bus_num in sorted(stats.keys()):
            if bus_num not in self._i2c_utilization_widgets:
                self._i2c_utilization_widgets[bus_num] = Sparkline(self, f'I2C bus {bus_num}', '{:.1f}', '%')
                self._i2c_utilization_layout.addWidget(QLabel(bus_num))
                self._i2c_utilization_layout.addWidget(self._i2c_utilization_widgets[bus_num])
            else:
                busy_delta = stats[bus_num]['busy_time_us'] - self._i2c_prev_busy[bus_num]
                elapsed_delta = stats[bus_num]['time_since_register_us'] - self._i2c_prev_elapsed[bus_num]
                utilization = round(100 * busy_delta / elapsed_delta, 1)
                self._i2c_utilization_widgets[bus_num].add(utilization)

            self._i2c_prev_busy[bus_num] = stats[bus_num]['busy_time_us']
            self._i2c_prev_elapsed[bus_num] = stats[bus_num]['time_since_register_us']

    # ===================================== Digital board =====================================

    def _add_digital_board(self) -> QGroupBox:
        """
        Add the group showing all of the statistics from the digital board.
        """
        group = QGroupBox(self, title='Digital board')
        layout = QVBoxLayout()
        layout.setContentsMargins(_LAYOUT_MARGINS)
        layout.setSpacing(_LAYOUT_SPACING)
        layout.addWidget(self._add_digital_board_temperatures(), stretch=1)
        layout.addWidget(self._add_digital_board_power_monitors(), stretch=1)
        layout.addItem(QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding))
        group.setLayout(layout)
        self._db_layout = layout
        return group

    def _update_digital_board(self, stats):
        self._update_digital_board_temperatures(stats['temperatures'])
        self._update_digital_board_power_monitors(stats['power_monitors'])

    def _add_digital_board_temperatures(self) -> QGroupBox:
        """
        Add the group box and labels that show the digital board temperatures.
        """
        group = QGroupBox(self, title='Temperatures')
        self._db_temp_layout = QHBoxLayout()
        self._db_temps = {}

        group.setLayout(self._db_temp_layout)
        return group

    def _update_digital_board_temperatures(self, temperatures: dict[str, float]) -> None:
        for name, value in temperatures.items():
            if name not in self._db_temps:
                # Create the Sparkline widgets as required once the names of the
                # sensors are known.
                temp_widget = Sparkline(self, f'DB temperatures {name}', '{:.1f}', ' °C', is_err=lambda v: v >= 90, is_warn=lambda v: v >= 80)
                temp_widget.setToolTip(name)
                self._db_temps[name] = temp_widget
                self._db_temp_layout.addWidget(temp_widget)
                self._db_temp_layout.setStretch(len(self._db_temps) - 1, 1)

            self._db_temps[name].add(value)

    def _update_power_monitors(self,
                               layout: QGridLayout,
                               board_name: str,
                               widgets: dict[str, dict[str, Sparkline]],
                               stats: dict[str, dict[str, float]]):
        if layout.count() == 0:
            # Populate the widgets in the layout on the first update, once the
            # names of the power rails are known.
            for row, rail_name in enumerate(stats.keys()):
                rail_name_label = QLabel(rail_name)
                rail_name_label.setStyleSheet('font-family: "Nimbus Sans Narrow";')
                layout.addWidget(rail_name_label, row, 0)
                for col, measurement in enumerate(stats[rail_name].keys(), start=1):
                    widget = Sparkline(self, f'{board_name} {rail_name} {measurement}', _FORMAT[measurement], _UNITS[measurement])
                    widgets[rail_name][measurement] = widget
                    layout.addWidget(widget, row, col)

        for rail_name, values in stats.items():
            for measurement, value in values.items():
                widgets[rail_name][measurement].add(value)

    def _add_digital_board_power_monitors(self) -> QGroupBox:
        group = QGroupBox(self, title='Power monitors')
        layout = QGridLayout()
        layout.setContentsMargins(_LAYOUT_MARGINS)
        layout.setSpacing(_LAYOUT_SPACING)
        self._db_pm_widgets = defaultdict(dict)
        group.setLayout(layout)
        self._db_pm_layout = layout
        return group

    def _update_digital_board_power_monitors(self, stats: dict[str, dict[str, float]]):
        if self._db_pm_layout.count() == 0:
            # Update the stretch factor now that the number of power monitors is known.
            self._db_layout.setStretch(1, len(stats.keys()))
        self._update_power_monitors(self._db_pm_layout, 'DB', self._db_pm_widgets, stats)

    # ===================================== Carrier board =====================================

    def _add_carrier_board_and_gnss_module(self) -> QVBoxLayout:
        layout = QVBoxLayout()
        layout.setContentsMargins(_LAYOUT_MARGINS)
        layout.setSpacing(_LAYOUT_SPACING)
        layout.addWidget(self._add_carrier_board())
        layout.addWidget(self._add_gnss_module())
        return layout

    def _add_carrier_board(self) -> QGroupBox:
        """
        Add the group showing all of the statistics from the carrier board.
        """
        group = QGroupBox(self, title='Carrier board')
        layout = QVBoxLayout()
        layout.setContentsMargins(_LAYOUT_MARGINS)
        layout.setSpacing(_LAYOUT_SPACING)
        layout.addWidget(self._add_carrier_board_fan_controller(), stretch=2)
        layout.addWidget(self._add_carrier_board_power_monitors(), stretch=1)
        group.setLayout(layout)
        self._cb_layout = layout

        self._cb_group = group
        return group

    def _update_carrier_board(self, stats):
        if stats:
            self._update_carrier_board_fan_controller(stats['fan_controller'])
            self._update_carrier_board_power_monitors(stats['power_monitors'])
        else:
            self._cb_group.setEnabled(False)

    def _add_carrier_board_fan_controller(self) -> QGroupBox:
        """
        Add the widgets to show the statistics from the fan controller.

        This uses the following grid layout:
        +--------------+---------+---------+
        | Temperatures | <temp1> | <temp2> |
        +--------------+---------+---------+
        |              |   Fan 1 |   Fan 2 |
        +--------------+---------+---------+
        | PWM          |  <pwm1> |  <pwm2> |
        +--------------+---------+---------+
        | RPM          |  <rpm1> |  <rpm2> |
        +--------------+---------+---------+
        """
        group = QGroupBox(self, title='Fans')
        layout = QGridLayout()
        self._cb_fan_widgets = dict()
        fans = (1, 2)

        layout.addWidget(QLabel('Temperatures'), 0, 0)
        for col in fans:
            widget = Sparkline(self, f'fan temp {col}', _FORMAT['temperature'], _UNITS['temperature'])
            widget.setToolTip(('Internal' if col == 1 else 'External') + ' temperature sensor')
            self._cb_fan_widgets[f'temperature{col}'] = widget
            layout.addWidget(widget, 0, col)

            label = QLabel(f'Fan {col}')
            label.setAlignment(Qt.AlignBottom | Qt.AlignHCenter)
            layout.addWidget(label, 1, col)

        for row, measurement in enumerate(('PWM', 'RPM'), start=2):
            layout.addWidget(QLabel(measurement), row, 0)
            for col in fans:
                widget = Sparkline(self, f'fan {col} {measurement}', _FORMAT[measurement], _UNITS[measurement])
                self._cb_fan_widgets[f'{measurement.lower()}{col}'] = widget
                layout.addWidget(widget, row, col)

        group.setLayout(layout)
        return group

    def _update_carrier_board_fan_controller(self, stats: dict[str, float]):
        for field, value in stats.items():
            if field.startswith('pwm'):
                value = 100 * value // 120
            self._cb_fan_widgets[field].add(value)

    def _add_carrier_board_power_monitors(self) -> QGroupBox:
        group = QGroupBox(self, title='Power monitors')
        layout = QGridLayout()
        layout.setContentsMargins(_LAYOUT_MARGINS)
        layout.setSpacing(_LAYOUT_SPACING)
        self._cb_pm_widgets = defaultdict(dict)
        group.setLayout(layout)
        self._cb_pm_layout = layout
        return group

    def _update_carrier_board_power_monitors(self, stats: dict[str, dict[str, float]]):
        if self._cb_pm_layout.count() == 0:
            # Update the stretch factor now that the number of power monitors is known.
            self._cb_layout.setStretch(1, len(stats.keys()))
        self._update_power_monitors(self._cb_pm_layout, 'CB', self._cb_pm_widgets, stats)

    # ===================================== GNSS module ======================================

    def _add_gnss_module(self) -> QGroupBox:
        """
        Add the group showing all of the statistics from the GNSS module.
        """
        self._gnss_group = QGroupBox(self, title='GNSS module')
        layout = QVBoxLayout()
        layout.setContentsMargins(_LAYOUT_MARGINS)
        layout.setSpacing(_LAYOUT_SPACING)
        self._gnss_group.setLayout(layout)

        layout.addLayout(self._add_gnss_utc_time())
        layout.addWidget(self._add_gnss_antenna())
        layout.addWidget(self._add_gnss_satellites(), stretch=1)
        layout.addWidget(self._add_gnss_signals())

        return self._gnss_group

    def _update_gnss_module(self, stats: dict) -> None:
        if not stats or not stats['present']:
            # allow for a missing board that has no stats
            self._gnss_group.setEnabled(False)
            self._gnss_utc_time.setText('')
            self._gnss_utc_time_valid.setText('')
            self._update_gnss_antenna(None)
            self._gnss_plotter.update([])
            self._gnss_signals.setText('')
        else:
            self._gnss_group.setEnabled(True)
            self._gnss_utc_time.setText(stats['utc_time'])
            self._gnss_utc_time_valid.setText('(valid)' if stats['utc_time_valid'] else '(not valid)')
            self._update_gnss_antenna(stats['antenna'])
            self._gnss_plotter.update(stats['satellites'])
            self._gnss_signals.setText(', '.join(stats['signals']))

    def _add_gnss_utc_time(self) -> QHBoxLayout:
        """
        Add some text widgets to show the current UTC time, and whether it is valid or not.
        """
        layout = QHBoxLayout()
        layout.addWidget(QLabel('UTC time:'))
        self._gnss_utc_time = QLabel('')
        layout.addWidget(self._gnss_utc_time)
        self._gnss_utc_time_valid = QLabel('')
        layout.addWidget(self._gnss_utc_time_valid)
        return layout

    def _add_gnss_antenna(self) -> QGroupBox:
        """
        Add the group box showing the information about the GNSS antenna.
        """
        self._gnss_block_id = []
        self._gnss_power = []
        self._gnss_status = []

        group = QGroupBox(self, title='Antenna')
        layout = QGridLayout()
        layout.setContentsMargins(_LAYOUT_MARGINS)
        layout.setSpacing(_LAYOUT_SPACING)
        group.setLayout(layout)
        for row in range(2):
            layout.addWidget(QLabel('Block:'), row, 0)
            self._gnss_block_id.append(QLabel(''))
            layout.addWidget(self._gnss_block_id[-1], row, 1)

            layout.addWidget(QLabel('Power:'), row, 2)
            self._gnss_power.append(QLabel(''))
            layout.addWidget(self._gnss_power[-1], row, 3)

            layout.addWidget(QLabel('Status:'), row, 4)
            self._gnss_status.append(QLabel(''))
            layout.addWidget(self._gnss_status[-1], row, 5)

        return group

    def _update_gnss_antenna(self, stats: dict) -> None:
        if stats is None:
            for i in range(len(self._gnss_block_id)):
                self._gnss_block_id[i].setText('')
                self._gnss_power[i].setText('')
                self._gnss_status[i].setText('')
        else:
            for i, ant in enumerate(stats):
                self._gnss_block_id[i].setText(str(ant['block_id']))
                self._gnss_power[i].setText(ant['ant_power'])
                self._gnss_status[i].setText(ant['ant_status'])

    def _add_gnss_satellites(self) -> QGroupBox:
        """
        Add a group box showing the information about all of the satellites that
        the GNSS module is tracking.
        """
        group = QGroupBox(self, title='Satellites')
        layout = QHBoxLayout()
        layout.setContentsMargins(_LAYOUT_MARGINS)
        layout.setSpacing(_LAYOUT_SPACING)
        self._gnss_plotter = SatellitePlotter(self)
        layout.addWidget(self._gnss_plotter)
        group.setLayout(layout)
        return group

    def _add_gnss_signals(self) -> QGroupBox:
        """
        Add a group box showing which GNSS signals are currently configured for use.
        """
        group = QGroupBox(self, title='Signals')
        layout = QHBoxLayout()
        layout.setContentsMargins(_LAYOUT_MARGINS)
        layout.setSpacing(_LAYOUT_SPACING)
        self._gnss_signals = QLabel()
        self._gnss_signals.setWordWrap(True)
        self._gnss_signals.setStyleSheet('font-family: "Nimbus Sans Narrow";')
        layout.addWidget(self._gnss_signals)
        group.setLayout(layout)
        return group

    # ===================================== Input boards =====================================
    _IB_POWER_GOOD_LABELS = (
        '+2V5_CLK',
        '-2V5_AFE',
        '+2V5_AFE',
        '+3V0_REF',
        '+3V3_AFE',
        '-5V0_AFE',
        '+5V0_AFE',
        '1V1_1_PG',
        '1V1_2_PG1',
        '1V1_2_PG2',
        '3V3_AD4080_PG',
        'VADJ',
    )

    def _add_input_board(self, ib_idx: int) -> QGroupBox:
        """
        Add the group box and labels that show the statistics for one input board.
        """
        title = f'Input board {ib_idx + 1}'
        if ib_idx == 0:
            group = QGroupBox(self, title=title)
        else:
            group = GroupBoxWithMenu(self, title=title)
        layout = QVBoxLayout()
        layout.setContentsMargins(_LAYOUT_MARGINS)

        layout.addLayout(self._add_board_serial_number_and_firmware_version('input'))
        layout.addLayout(self._add_input_board_relay_types())
        layout.addLayout(self._add_board_power_good('input', ib_idx, self._IB_POWER_GOOD_LABELS))
        layout.addWidget(self._add_input_board_power_monitors(ib_idx))
        layout.addLayout(self._add_input_board_over_voltage(ib_idx))
        layout.addWidget(self._add_input_board_temperatures(ib_idx))
        group.setLayout(layout)

        if not hasattr(self, '_ib_group'):
            self._ib_group = []
        self._ib_group.append(group)

        return group

    def _update_input_board(self, ib_idx: int, stats) -> None:
        if not stats:
            # allow for a missing board that has no stats
            self._ib_group[ib_idx].setEnabled(False)
        else:
            self._update_board_serial_number_and_firmware_version('input', ib_idx, stats)
            self._update_input_board_relay_types(ib_idx, stats['relay_types'])
            self._update_board_power_good('input', ib_idx, stats['power_good'])
            self._update_input_board_temperatures(ib_idx, stats)
            self._update_input_board_power_monitors(ib_idx, stats['voltages'], stats['power_good'])

    def _add_board_serial_number_and_firmware_version(self, board_type: str) -> QHBoxLayout:
        """
        Add the widgets that show the input or output board's serial number and
        firmware version.
        """
        if not hasattr(self, '_serial_number'):
            self._serial_number = defaultdict(list)
            self._firmware_version = defaultdict(list)
            self._hardware_revision = defaultdict(list)

        layout = QHBoxLayout()

        layout.addWidget(QLabel('SN:'))
        widget = QLabel('')
        self._serial_number[board_type].append(widget)
        layout.addWidget(widget)

        layout.addItem(QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Minimum))

        layout.addWidget(QLabel('FW ver:'))
        widget = QLabel('?')
        self._firmware_version[board_type].append(widget)
        layout.addWidget(widget)

        layout.addWidget(QLabel(' HW rev:'))
        widget = QLabel('?')
        self._hardware_revision[board_type].append(widget)
        layout.addWidget(widget)

        return layout

    def _update_board_serial_number_and_firmware_version(self, board_type: str, board_idx: int, stats) -> None:
        self._serial_number[board_type][board_idx].setText(stats['pcba_serial_number'])
        self._firmware_version[board_type][board_idx].setText(str(stats['firmware_version']))
        if 'hardware_revision' in stats:
            self._hardware_revision[board_type][board_idx].setText(str(stats['hardware_revision']))

    def _add_input_board_relay_types(self) -> QHBoxLayout:
        if not hasattr(self, '_relay_types'):
            self._relay_types = []

        layout = QHBoxLayout()

        layout.addWidget(QLabel('Relay types:'))
        widget = QLabel('')
        self._relay_types.append(widget)
        layout.addWidget(widget)

        return layout

    def _update_input_board_relay_types(self, ib_idx: int, relay_types: list[str]) -> None:
        self._relay_types[ib_idx].setText(' '.join([i.lower() for i in relay_types]))

    def _add_board_power_good(self, board_type: str, board_idx: int, labels: tuple[str]) -> QHBoxLayout:
        """
        Add the collection of check boxes that are used to show the power good
        bit flags for an input or output board.
        """
        # Storage for the widgets that show the power good flags.
        if not hasattr(self, '_power_good'):
            self._power_good = defaultdict(list)
        self._power_good[board_type].append([])

        layout = QHBoxLayout()
        layout.addWidget(QLabel('Power good:'))
        for label in labels:
            widget = QCheckBox(self)
            widget.setStyleSheet('QCheckBox::indicator:checked { background-color: green; } '
                                 'QCheckBox::indicator:unchecked { background-color: red; }')
            widget.setTristate(True)
            widget.setCheckState(Qt.PartiallyChecked)
            widget.setEnabled(False)
            widget.setToolTip(label)
            self._power_good[board_type][board_idx].append(widget)
            layout.addWidget(widget)

        return layout

    def _update_board_power_good(self, board_type: str, board_idx: int, power_good: int) -> None:
        for i, widget in enumerate(self._power_good[board_type][board_idx]):
            widget.setChecked(power_good & (1 << i) != 0)

    _IB_POWER_RAILS = ('-2V5_AFE', '+2V5_AFE', '+2V5_CLK', '+3V0_REF', '+3V3_AFE', '-5V0_AFE', '+5V0_AFE', 'VADJ')

    def _add_input_board_power_monitors(self, ib_idx: int) -> QGroupBox:
        """
        Add the collection of voltages read from the input board.
        """
        # Storage for the widgets that show the voltages.
        board_widgets = {}
        if ib_idx == 0:
            self._ib_pm_widgets: list[dict[str, Sparkline]] = []
        self._ib_pm_widgets.append(board_widgets)

        group = QGroupBox(self, title='Power monitors')
        layout = QGridLayout()
        for i, rail_name in enumerate(self._IB_POWER_RAILS):
            layout.addWidget(QLabel(rail_name), i // 2, 2 * (i % 2))
            widget = Sparkline(self, f'IB{ib_idx} {rail_name}', '{:.3f}', ' V')
            board_widgets[rail_name] = widget
            layout.addWidget(widget, i // 2, 2 * (i % 2) + 1)
        group.setLayout(layout)
        return group

    def _update_input_board_power_monitors(self, ib_idx: int, stats: dict[str, float], power_good: int) -> None:
        board_widgets = self._ib_pm_widgets[ib_idx]
        for rail_name, value in stats.items():
            # This value is in error if the corresponding power_good bit shows in error.
            bit_idx = self._IB_POWER_GOOD_LABELS.index(rail_name)
            is_err = ((power_good & (1 << bit_idx)) == 0)

            board_widgets[rail_name].add(value, is_err)

    def _add_input_board_over_voltage(self, ib_idx: int) -> QVBoxLayout:
        """
        Add the over voltage flags and values read from the input board.
        """
        # Storage for the widgets that show the voltages.
        if ib_idx == 0:
            self._ib_ov_ints = [[]]
            self._ib_ov_values = [[]]
        else:
            self._ib_ov_ints.append([])
            self._ib_ov_values.append([])

        ov_layout = QVBoxLayout()

        # Check boxes to show the over voltage interrupt flags.
        ov_ints_layout = QHBoxLayout()
        ov_ints_layout.addWidget(QLabel('Over voltage:'))
        for ch_idx in range(4):
            widget = QCheckBox(self)
            widget.setStyleSheet('QCheckBox::indicator:checked { background-color: green; } '
                                 'QCheckBox::indicator:unchecked { background-color: red; }')
            widget.setTristate(True)
            widget.setCheckState(Qt.PartiallyChecked)
            widget.setEnabled(False)
            widget.setToolTip(f'Channel {ib_idx * 4 + ch_idx + 1}')
            self._ib_ov_ints[ib_idx].append(widget)
            ov_ints_layout.addWidget(widget)
        ov_layout.addLayout(ov_ints_layout)

        # Sparklines to show the over voltage values.
        ov_values_layout = QHBoxLayout()
        for ch_idx in range(4):
            widget = Sparkline(self, f'IB{ib_idx} over voltage {ch_idx}', '{:.3f}', ' V')
            widget.setToolTip(f'Channel {ib_idx * 4 + ch_idx + 1}')
            self._ib_ov_values[ib_idx].append(widget)
            ov_values_layout.addWidget(widget)
        ov_layout.addLayout(ov_values_layout)

        return ov_layout

    # The labels and corresponding tooltips of the input board temperature sensors.
    # These names are from the schematics.
    _IB_TEMPS = {
        'U1': 'The TMP468 temperature sensor, which is close to the STM32',
        'D2': 'Near the TP1 touch points at the B2B end of the board',
        'D3': 'Near U6, the I2C to SPI bridge for channels A and B',
    }

    def _add_input_board_temperatures(self, ib_idx: int) -> QGroupBox:
        """
        Add the group box and labels that show the temperatures for one input board.
        """
        group = QGroupBox(self, title='Temperatures')
        layout = QHBoxLayout()

        # Storage for the widgets that show the temperatures.
        if ib_idx == 0:
            self._ib_temp = []
        self._ib_temp.append([])

        for name, tooltip in self._IB_TEMPS.items():
            temp_widget = Sparkline(self, f'IB{ib_idx} {name}', '{:.1f}', ' °C')

            # Add a tool tip that describes where this temperature comes from.
            temp_widget.setToolTip(tooltip + f' ({name}).')

            self._ib_temp[ib_idx].append(temp_widget)

            # Add this widget to the group's grid layout, spreading the Sparkline widgets
            # across multiple rows.
            layout.addWidget(temp_widget)

        group.setLayout(layout)
        return group

    def _update_input_board_temperatures(self, ib_idx: int, stats) -> None:
        for i in range(len(self._IB_TEMPS)):
            value = stats['temperatures'][i]
            self._ib_temp[ib_idx][i].add(value)

    # ===================================== Balun boards =====================================

    def _add_balun_board(self, bb_idx: int) -> QGroupBox:
        """
        Add the group box and labels that show the statistics for one Balun board.
        """
        group = QGroupBox(self, title=f'Balun board {bb_idx + 1}')
        layout = QVBoxLayout()
        layout.setContentsMargins(_LAYOUT_MARGINS)

        layout.addLayout(self._add_board_serial_number_and_firmware_version('balun'))
        group.setLayout(layout)

        if not hasattr(self, '_bb_group'):
            self._bb_group = []
        self._bb_group.append(group)

        return group

    def _update_balun_board(self, bb_idx: int, stats) -> None:
        if not stats:
            # allow for a missing board that has no stats
            self._bb_group[bb_idx].setEnabled(False)
        else:
            self._update_board_serial_number_and_firmware_version('balun', bb_idx, stats)

    # ===================================== Output boards =====================================

    _OB_POWER_GOOD_LABELS = ('+14V0', '+2V5', '-2V5', '-14V0', 'N14V0_IMON', 'P14V0_IMON')

    def _add_output_board(self, ob_idx: int) -> QGroupBox:
        """
        Add the group box and labels that show the statistics for one output board.
        """
        group = QGroupBox(self, title=f'Output board {ob_idx + 1}')
        layout = QVBoxLayout()
        layout.setContentsMargins(_LAYOUT_MARGINS)

        layout.addLayout(self._add_board_serial_number_and_firmware_version('output'))
        layout.addLayout(self._add_board_power_good('output', ob_idx, self._OB_POWER_GOOD_LABELS))
        layout.addWidget(self._add_output_board_power_monitors(ob_idx))
        layout.addWidget(self._add_output_board_temperatures(ob_idx))
        group.setLayout(layout)

        if not hasattr(self, '_ob_group'):
            self._ob_group = []
        self._ob_group.append(group)

        return group

    def _update_output_board(self, ob_idx: int, stats) -> None:
        if not stats:
            # allow for a missing board that has no stats
            self._ob_group[ob_idx].setEnabled(False)
        else:
            self._update_board_serial_number_and_firmware_version('output', ob_idx, stats)
            self._update_board_power_good('output', ob_idx, stats['power_good'])
            self._update_output_board_temperatures(ob_idx, stats)
            self._update_output_board_power_monitors(ob_idx, stats)

    # The labels to use for the output board temperature sensors.
    # These names are from the schematics.
    _OB_TEMP_NAMES = (       'D5',  'U12', 'D6',  # noqa
                      'D1',  'D2',  'D3',  'D4',  # noqa
                      'U3D', 'U3C', 'U3B', 'U3A')  # noqa

    def _add_output_board_temperatures(self, ob_idx: int) -> QGroupBox:
        """
        Add the group box and labels that show the temperatures for one output board.

        The arrangement of the temperatures on the GUI is approximately the same as the
        position of the temperature sensors in the physical device.  The columns from
        left-to-right matches the channels when viewed from the front of the device.
        The top-to-bottom position within a board group are from the far end of the
        board to the front (connector) end of the board.
        """
        group = QGroupBox(self, title='Temperatures')
        layout = QGridLayout()
        row = 0
        col = 1  # position 0,0 is empty

        # Storage for the widgets that show the temperatures.
        if ob_idx == 0:
            self._ob_temp = []
        self._ob_temp.append([])

        # These names are from the schematics.
        for name in self._OB_TEMP_NAMES:
            # The widget that displays the temperature.
            if name.startswith('U3'):
                temp_widget = Sparkline(self, f'OB{ob_idx} {name}', '{:.1f}', ' °C',
                                        is_err=lambda v: v >= 150, is_warn=lambda v: v >= 125)
            else:
                temp_widget = Sparkline(self, f'OB{ob_idx} {name}', '{:.1f}', ' °C')

            # Add a tool tip that describes where this temperature comes from.
            row0_tooltip = f' for channels {ob_idx * 4 + 1} to {(ob_idx + 1) * 4}'
            if name == 'D5':
                tooltip = 'Near the +2V5 and +14V0 supplies' + row0_tooltip
            elif name == 'D6':
                tooltip = 'Near the -2V5 and -14V0 supplies' + row0_tooltip
            elif name == 'U12':
                tooltip = 'Inside the temperature sensor' + row0_tooltip
            elif name in ('D1', 'D2', 'D3', 'D4'):
                tooltip = f'Near the relays and amplifier for channel {ob_idx * 4 + col + 1}'
            elif name.startswith('U3'):
                tooltip = f'Inside the THS3491 amplifier for channel {ob_idx * 4 + col + 1}'
            tooltip += f' ({name}).'
            temp_widget.setToolTip(tooltip)

            self._ob_temp[ob_idx].append(temp_widget)

            # Add this widget to the group's grid layout, spreading the Sparkline widgets
            # across multiple rows.
            layout.addWidget(temp_widget, row, col)
            if name == 'D6' or name == 'D4':
                row += 1
                col = 0
            else:
                col += 1

        group.setLayout(layout)
        return group

    # Map the index in OutputBoard.temperatures[] to Sparkline widgets in self._ob_temp[][].
    OB_TEMP_MAP = (1, 3, 4, 5, 6, 0, 2)

    # Map the index in OutputBoard.ths3491_temperatures[] to Sparkline widgets in self._ob_temp[][].
    OB_THS_MAP = (10, 9, 8, 7)

    def _update_output_board_temperatures(self, ob_idx: int, stats) -> None:
        for i in range(len(self._OB_TEMP_NAMES)):
            widget_idx = self.OB_TEMP_MAP[i] if i < 7 else self.OB_THS_MAP[i - 7]
            value = stats['temperatures'][i] if i < 7 else stats['ths3491_temperatures'][i - 7]
            self._ob_temp[ob_idx][widget_idx].add(value)

    # Digital board power monitors.
    _OB_POWER_RAILS = ('+14V0', '+2V5', '-2V5', '-14V0')
    _OB_POWER_MEASUREMENTS = ('voltages', 'currents')

    def _add_output_board_power_monitors(self, ob_idx: int) -> QGroupBox:
        """
        Add the group box and labels that show the power monitors for one output board.
        """
        # Storage for the widgets that show the voltages and currents.
        board_widgets = defaultdict(dict)
        if ob_idx == 0:
            self._ob_pm_widgets = []
        self._ob_pm_widgets.append(board_widgets)

        group = QGroupBox(self, title='Power monitors')
        layout = QGridLayout()
        for col, rail_name in enumerate(self._OB_POWER_RAILS):
            layout.addWidget(QLabel(rail_name), 0, col, Qt.AlignHCenter)
            for row, measurement in enumerate(self._OB_POWER_MEASUREMENTS, start=1):
                # All rails have voltages, only the 14V0 rails have currents.
                if measurement == 'voltages' or '14' in rail_name:
                    widget = Sparkline(self, f'OB{ob_idx} {rail_name} {measurement}', _FORMAT[measurement], _UNITS[measurement])
                    board_widgets[rail_name][measurement] = widget
                    layout.addWidget(widget, row, col)
        group.setLayout(layout)
        return group

    def _update_output_board_power_monitors(self, ob_idx: int, stats: dict[str, dict[str, float]]) -> None:
        board_widgets = self._ob_pm_widgets[ob_idx]
        for rail_name in self._OB_POWER_RAILS:
            for measurement in self._OB_POWER_MEASUREMENTS:
                if measurement in board_widgets[rail_name]:
                    # Determine if the corresponding bit in power_good indicates an error.
                    if measurement == 'currents':
                        label = 'N14V0_IMON' if rail_name == '-14V0' else 'N14V0_IMON'
                    else:
                        label = rail_name
                    bit_idx = self._OB_POWER_GOOD_LABELS.index(label)
                    is_err = ((stats['power_good'] & (1 << bit_idx)) == 0)

                    board_widgets[rail_name][measurement].add(stats[measurement][rail_name], is_err)

    def _add_mokupro_voltages(self):
        """
        Add the group showing the Moku:Pro voltages.
        """
        # Include the menu in the title of this group box as this is the right-most group box.
        group = QGroupBox(self, title='Voltages')
        self._voltages_layout = QGridLayout()
        group.setLayout(self._voltages_layout)
        return group

    def _update_mokupro_voltages(self, voltages: dict[str, float]) -> None:
        if self._voltages_layout.count() == 0:
            # Populate the widgets in the layout on the first update, once the
            # names of the voltage sensors are known.
            self._voltages_widgets = {}
            for row, name in enumerate(voltages.keys()):
                self._voltages_layout.addWidget(QLabel(name), row, 0)
                widget = Sparkline(self, name, _FORMAT['voltage'], _UNITS['voltage'])
                self._voltages_layout.addWidget(widget, row, 1)
                self._voltages_widgets[name] = widget
                self._voltages_layout.setRowStretch(row, 1)

        for name, value in voltages.items():
            self._voltages_widgets[name].add(value)

    def _add_temperatures(self):
        """
        Add the group showing the Moku:Lab and Moku:Pro temperatures.
        """
        # Include the menu in the title of this group box as this is the right-most group box.
        group = GroupBoxWithMenu(self, title='Temperatures')
        self._temperatures_layout = QGridLayout()
        group.setLayout(self._temperatures_layout)
        return group

    def _update_temperatures(self, temperatures: dict[str, float]) -> None:
        if self._temperatures_layout.count() == 0:
            # Populate the widgets in the layout on the first update, once the
            # names of the temperature sensors are known.
            self._temperatures_widgets = {}
            for row, name in enumerate(temperatures.keys()):
                self._temperatures_layout.addWidget(QLabel(name), row, 0)
                widget = Sparkline(self, name, _FORMAT['temperature'], _UNITS['temperature'])
                self._temperatures_layout.addWidget(widget, row, 1)
                self._temperatures_widgets[name] = widget
                self._temperatures_layout.setRowStretch(row, 1)

            # Add a blank expanding row at the bottom so that the Sparklines aren't
            # stretched vertically too much.
            row = len(temperatures)
            self._temperatures_layout.addItem(QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding), row, 0)
            self._temperatures_layout.setRowStretch(row, 2 if row > 10 else 10)

        for name, value in temperatures.items():
            self._temperatures_widgets[name].add(value)
