Source code for QPolargraph.FlashFirmware

'''FlashFirmware — Arduino firmware installer for QPolargraph.

Detects attached Arduino boards via ``QSerialPortInfo``, installs any
missing Arduino libraries, and flashes the bundled ``acam3`` firmware
using ``arduino-cli``.

Requires ``arduino-cli`` to be installed and on ``PATH``.  See
https://arduino.github.io/arduino-cli/ for installation instructions.

Flash sequence
--------------
When the user clicks *Flash Firmware*, the following steps run in order
in a background thread:

1. **Check libraries** — compares ``ARDUINO_LIBS`` against the output of
   ``arduino-cli lib list``.  Any missing libraries are installed
   automatically via ``arduino-cli lib install``.
2. **Compile** — ``arduino-cli compile --fqbn <fqbn> hardware/arduino/acam3/``
3. **Upload** — ``arduino-cli upload --fqbn <fqbn> --port <port> hardware/arduino/acam3/``

Each step streams its output to the dialog's text area in real time.
The flash button is re-enabled on completion, and a ``QMessageBox``
reports success or failure.

Standalone usage
----------------
Run directly from the command line::

    qpolargraph-flash

or::

    python -m QPolargraph.FlashFirmware

Integration into a QMainWindow application
-------------------------------------------
``FlashDialog`` is a standard ``QDialog`` and can be wired to a menu
action in any ``QMainWindow`` subclass::

    from QPolargraph.FlashFirmware import FlashDialog

    flash_action = QtWidgets.QAction('Flash Arduino Firmware...', self)
    flash_action.triggered.connect(lambda: FlashDialog(self).exec())
    tools_menu.addAction(flash_action)

Passing ``self`` as the parent centres the dialog over the application
window.  The import can be deferred to the slot so that ``FlashFirmware``
is only loaded when the user triggers the action.
'''

from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path

from qtpy import QtCore, QtWidgets
from qtpy.QtSerialPort import QSerialPortInfo
from QPolargraph.hardware.Motors import Motors
FIRMWARE_VERSION = Motors.FIRMWARE_VERSION


ARDUINO_VIDS = {
    0x2341,  # Arduino LLC (official boards)
    0x2A03,  # Arduino SRL
    0x1A86,  # QinHeng CH340 (common clones)
    0x0403,  # FTDI (some clones)
}

SKETCH = Path(__file__).parent / 'hardware' / 'arduino' / 'acam3'
DEFAULT_FQBN = 'arduino:avr:uno'
ARDUINO_LIBS = [
    'Adafruit Motor Shield V2 Library',
    'AccelStepper',
]



[docs] def find_arduinos() -> list[QSerialPortInfo]: '''Return serial ports whose USB vendor ID matches a known Arduino VID.''' return [p for p in QSerialPortInfo.availablePorts() if p.vendorIdentifier() in ARDUINO_VIDS]
def detect_fqbn(port_name: str) -> str: '''Ask arduino-cli for the board FQBN; fall back to DEFAULT_FQBN.''' try: result = subprocess.run( ['arduino-cli', 'board', 'list', '--format', 'json'], capture_output=True, text=True, timeout=10 ) data = json.loads(result.stdout) # arduino-cli v1 wraps output in {'detected_ports': [...]} ports = (data.get('detected_ports', []) if isinstance(data, dict) else data) for entry in ports: address = entry.get('port', {}).get('address', '') if address == port_name: matching = entry.get('matching_boards', []) if matching: return matching[0].get('fqbn', DEFAULT_FQBN) except Exception: pass return DEFAULT_FQBN class _FlashWorker(QtCore.QThread): '''Background thread that installs libraries, compiles, and uploads acam3. Runs three steps in sequence: library check/install, ``arduino-cli compile``, and ``arduino-cli upload``. Progress is streamed line by line via the ``output`` signal. The ``finished`` signal carries ``True`` on success and ``False`` on any failure. ''' output = QtCore.Signal(str) finished = QtCore.Signal(bool) def __init__(self, port: str, fqbn: str, parent=None): super().__init__(parent) self._port = port self._fqbn = fqbn def _run(self, *args, timeout: int = 120) -> bool: '''Run a subprocess; emit its output; return True on success.''' try: result = subprocess.run( args, capture_output=True, text=True, timeout=timeout ) except FileNotFoundError: self.output.emit( 'arduino-cli not found.\n' 'Install it from https://arduino.github.io/arduino-cli/' ) return False except subprocess.TimeoutExpired: self.output.emit('Operation timed out.') return False for line in (result.stdout + result.stderr).splitlines(): if line.strip(): self.output.emit(line) return result.returncode == 0 def _installed_libraries(self) -> set[str]: '''Return the names of currently installed arduino-cli libraries.''' try: result = subprocess.run( ['arduino-cli', 'lib', 'list', '--format', 'json'], capture_output=True, text=True, timeout=30 ) data = json.loads(result.stdout) libs = (data.get('installed_libraries', []) if isinstance(data, dict) else data) return {entry['library']['name'] for entry in libs} except Exception: return set() def _ensure_libraries(self) -> bool: '''Install missing and upgrade outdated Arduino libraries.''' self.output.emit('Checking Arduino libraries...') installed = self._installed_libraries() missing = [lib for lib in ARDUINO_LIBS if lib not in installed] for lib in missing: self.output.emit(f'Installing {lib}...') if not self._run('arduino-cli', 'lib', 'install', lib, timeout=60): return False present = [lib for lib in ARDUINO_LIBS if lib in installed] for lib in present: self.output.emit(f'Updating {lib}...') self._run('arduino-cli', 'lib', 'upgrade', lib, timeout=60) if not missing: self.output.emit('All required libraries are up to date.') return True def run(self) -> None: if not self._ensure_libraries(): self.finished.emit(False) return self.output.emit(f'Compiling for {self._fqbn}...') ok = self._run('arduino-cli', 'compile', '--fqbn', self._fqbn, str(SKETCH)) if not ok: self.finished.emit(False) return self.output.emit(f'Uploading to {self._port}...') ok = self._run('arduino-cli', 'upload', '--fqbn', self._fqbn, '--port', self._port, str(SKETCH), timeout=60) if not ok: self.finished.emit(False) return self.output.emit('Firmware installed successfully.') self.finished.emit(True)
[docs] class FlashDialog(QtWidgets.QDialog): '''Dialog to detect an attached Arduino and flash the acam3 firmware. Enumerates serial ports using ``QSerialPortInfo``, filters by known Arduino USB vendor IDs, and delegates the three-step flash sequence (library install, compile, upload) to a background :class:`_FlashWorker` thread. Requires ``arduino-cli`` to be installed and on ``PATH``. Parameters ---------- parent : QtWidgets.QWidget, optional Parent widget. message : str, optional Explanatory text shown above the form. Use this when the dialog is opened automatically (e.g. because no acam3 device was found) so the user understands why it appeared. ''' def __init__(self, parent: QtWidgets.QWidget | None = None, message: str | None = None): super().__init__(parent) self.setWindowTitle('Flash acam3 Firmware') self._worker: _FlashWorker | None = None self._setup_ui(message) self._populate() def _setup_ui(self, message: str | None = None) -> None: layout = QtWidgets.QVBoxLayout(self) if message: label = QtWidgets.QLabel(message) label.setWordWrap(True) layout.addWidget(label) form = QtWidgets.QFormLayout() self._port_combo = QtWidgets.QComboBox() label = f'acam3 v{FIRMWARE_VERSION}' if FIRMWARE_VERSION else 'acam3' form.addRow('Firmware:', QtWidgets.QLabel(label)) form.addRow('Arduino:', self._port_combo) layout.addLayout(form) self._output = QtWidgets.QPlainTextEdit() self._output.setReadOnly(True) self._output.setMinimumSize(520, 160) layout.addWidget(self._output) buttons = QtWidgets.QDialogButtonBox() self._flash_btn = buttons.addButton( 'Flash Firmware', QtWidgets.QDialogButtonBox.ButtonRole.ActionRole ) close_btn = buttons.addButton( QtWidgets.QDialogButtonBox.StandardButton.Close ) self._flash_btn.clicked.connect(self._flash) close_btn.clicked.connect(self.reject) layout.addWidget(buttons) def _populate(self) -> None: arduinos = find_arduinos() if not arduinos: self._output.appendPlainText('No Arduino detected.') self._flash_btn.setEnabled(False) return for port in arduinos: desc = port.description() or 'Unknown board' self._port_combo.addItem( f'{port.portName()}{desc}', userData=port.systemLocation() ) def _flash(self) -> None: port = self._port_combo.currentData() if not port: return self._flash_btn.setEnabled(False) self._output.clear() display = self._port_combo.currentText().split(' — ')[0] self._output.appendPlainText(f'Detecting board on {display}...') fqbn = detect_fqbn(port) self._output.appendPlainText(f'Board: {fqbn}') self._worker = _FlashWorker(port, fqbn, self) self._worker.output.connect(self._output.appendPlainText) self._worker.finished.connect(self._on_finished) self._worker.start() def _on_finished(self, success: bool) -> None: self._flash_btn.setEnabled(True) if success: self.accept() else: QtWidgets.QMessageBox.warning( self, 'Failed', 'Firmware installation failed.\nSee output for details.' )
def main() -> None: '''Entry point for the ``qpolargraph-flash`` GUI script.''' app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv) dialog = FlashDialog() dialog.exec() if __name__ == '__main__': main()