diff --git a/src/glider/gui/panels/hardware_panel.py b/src/glider/gui/panels/hardware_panel.py index 37b396b..1ba4006 100644 --- a/src/glider/gui/panels/hardware_panel.py +++ b/src/glider/gui/panels/hardware_panel.py @@ -396,71 +396,23 @@ def _toggle_port_row(): def _build_ble_address_row(self, layout, out: dict, dialog) -> None: """Add an editable BLE address combo with a Scan button to ``layout``. - Shared by every BLE-transport device in the Add Device dialog -- the - generic BLEWrite and the Maimu both need exactly this widget. The chosen - address lands in ``out["address"]``; read it back with - :meth:`_read_ble_address`. + Shared by every BLE-transport device in the Add Device dialog. The + widget itself lives in :mod:`glider.gui.widgets.schema_form`, so the + hand-built rows here and the schema-rendered form a plugin device gets + are the same widget rather than two that drift apart. """ - addr_combo = QComboBox() - addr_combo.setEditable(True) - addr_combo.setMinimumWidth(240) - addr_combo.lineEdit().setPlaceholderText("BLE address (or Scan)") - out["address"] = addr_combo - - scan_btn = QPushButton("Scan") - scan_btn.setToolTip("Discover nearby BLE peripherals (~5s)") - - def do_scan(_=False, combo=addr_combo, btn=scan_btn): - btn.setEnabled(False) - btn.setText("Scanning…") - - async def _scan(): - try: - # Scanning discovers peripherals via the host BLE adapter -- - # it does not depend on which board is selected, so scan - # directly via the BLE board's (static) scanner. - from glider.hal.boards.ble_board import BLEBoard - - results = await BLEBoard.scan(timeout=8.0) - combo.clear() - if not results: - combo.addItem("(no devices found)", None) - for nm, addr in results: - # Show the advertised name; fall back to the address for - # unnamed peripherals so they stay distinguishable. The - # address is kept as the item data (and tooltip) and is - # what gets saved. - label = nm if nm and nm != "(unknown)" else addr - combo.addItem(label, addr) - combo.setItemData(combo.count() - 1, addr, Qt.ItemDataRole.ToolTipRole) - except ImportError: - QMessageBox.critical(dialog, "Scan failed", "bleak is not installed.") - except Exception as e: # noqa: BLE001 - surfaced to user - QMessageBox.critical(dialog, "Scan failed", str(e)) - finally: - btn.setEnabled(True) - btn.setText("Scan") - - self._run_async(_scan()) - - scan_btn.clicked.connect(do_scan) - - addr_row = QHBoxLayout() - addr_row.addWidget(addr_combo) - addr_row.addWidget(scan_btn) - addr_container = QWidget() - addr_container.setLayout(addr_row) - layout.addRow("Address:", addr_container) + from glider.gui.widgets.schema_form import build_ble_address_widget + + container, combo = build_ble_address_widget(self._run_async, dialog) + out["address"] = combo + layout.addRow("Address:", container) @staticmethod def _read_ble_address(out: dict) -> str: """Read the address chosen in a row built by _build_ble_address_row.""" - addr = out["address"].currentData() - if not addr: - # Manually typed, or "addr (name)" picked without data. - raw = out["address"].currentText().strip() - addr = raw.split(" (")[0].strip() if raw else "" - return addr + from glider.gui.widgets.schema_form import read_schema_widget + + return read_schema_widget(out["address"], "ble_address") def _build_schema_widgets(self, layout, schema, out: dict) -> None: """Render a device SETTINGS_SCHEMA into a form layout. @@ -469,7 +421,7 @@ def _build_schema_widgets(self, layout, schema, out: dict) -> None: """ from glider.gui.widgets.schema_form import build_schema_widgets - build_schema_widgets(layout, schema, out) + build_schema_widgets(layout, schema, out, run_async=self._run_async) @staticmethod def _read_schema_widget(widget, ftype: str): diff --git a/src/glider/gui/widgets/schema_form.py b/src/glider/gui/widgets/schema_form.py index 5ae33ec..888a2bc 100644 --- a/src/glider/gui/widgets/schema_form.py +++ b/src/glider/gui/widgets/schema_form.py @@ -7,7 +7,15 @@ from __future__ import annotations -from PyQt6.QtWidgets import QCheckBox, QComboBox, QDoubleSpinBox, QLineEdit, QSpinBox +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import ( + QCheckBox, + QComboBox, + QDoubleSpinBox, + QLineEdit, + QSpinBox, + QWidget, +) def build_schema_widgets( @@ -17,6 +25,7 @@ def build_schema_widgets( *, values: dict | None = None, devices: dict | None = None, + run_async=None, ) -> None: """Render a SETTINGS_SCHEMA field list into ``layout`` and record widgets. @@ -80,6 +89,17 @@ def build_schema_widgets( widget.addItem(str(label), value) idx = widget.findData(default) widget.setCurrentIndex(idx if idx >= 0 else 0) + elif ftype == "ble_address": + # The widget added to the layout is a container (combo + Scan), but + # the value lives on the combo, so that is what gets stored. + container, widget = build_ble_address_widget(run_async, layout.parentWidget()) + if default: + widget.setCurrentText(str(default)) + if field.get("help"): + container.setToolTip(str(field["help"])) + out[key] = (widget, ftype) + layout.addRow(f"{field.get('label', key)}:", container) + continue elif ftype == "device_ref": widget = QComboBox() widget.addItem("-- None --", None) @@ -103,6 +123,73 @@ def build_schema_widgets( layout.addRow(f"{field.get('label', key)}:", widget) +def build_ble_address_widget(run_async=None, parent=None): + """An editable BLE address combo, with a Scan button when it can scan. + + Returns ``(container, combo)``: add the container to a layout, read the + value back from the combo with ``read_schema_widget(combo, "ble_address")``. + + Scanning is asynchronous, so the caller supplies ``run_async``. Without one + -- a form with no event loop to hand, such as a headless test -- the combo + is still returned and still editable; only the button is omitted. That + degradation is deliberate: an address can always be typed. + """ + from PyQt6.QtWidgets import QHBoxLayout, QMessageBox, QPushButton + + combo = QComboBox() + combo.setEditable(True) + combo.setMinimumWidth(240) + combo.lineEdit().setPlaceholderText("BLE address (or Scan)") + + container = QWidget(parent) + row = QHBoxLayout(container) + row.setContentsMargins(0, 0, 0, 0) + row.addWidget(combo) + + if run_async is None: + return container, combo + + scan_btn = QPushButton("Scan") + scan_btn.setToolTip("Discover nearby BLE peripherals (~5s)") + + def do_scan(_=False): + scan_btn.setEnabled(False) + scan_btn.setText("Scanning\u2026") + + async def _scan(): + try: + # Scanning discovers peripherals via the host BLE adapter -- it + # does not depend on which board is selected, so scan directly + # via the BLE board's (static) scanner. + from glider.hal.boards.ble_board import BLEBoard + + results = await BLEBoard.scan(timeout=8.0) + combo.clear() + if not results: + combo.addItem("(no devices found)", None) + for name, address in results: + # Show the advertised name; fall back to the address for + # unnamed peripherals so they stay distinguishable. The + # address is the item data (and tooltip) and is what gets + # saved. + label = name if name and name != "(unknown)" else address + combo.addItem(label, address) + combo.setItemData(combo.count() - 1, address, Qt.ItemDataRole.ToolTipRole) + except ImportError: + QMessageBox.critical(parent, "Scan failed", "bleak is not installed.") + except Exception as e: # noqa: BLE001 - surfaced to the user + QMessageBox.critical(parent, "Scan failed", str(e)) + finally: + scan_btn.setEnabled(True) + scan_btn.setText("Scan") + + run_async(_scan()) + + scan_btn.clicked.connect(do_scan) + row.addWidget(scan_btn) + return container, combo + + def read_schema_widget(widget, ftype: str): """Read the current value from a widget produced by :func:`build_schema_widgets`. @@ -118,6 +205,15 @@ def read_schema_widget(widget, ftype: str): return widget.value() if ftype == "bool": return widget.isChecked() + if ftype == "ble_address": + # An item picked from a scan carries the address as its data; anything + # typed by hand is the text, minus a trailing " (name)" if a scan label + # was pasted in. + address = widget.currentData() + if not address: + raw = widget.currentText().strip() + address = raw.split(" (")[0].strip() if raw else "" + return address if ftype in ("enum", "device_ref"): return widget.currentData() return widget.text().strip() diff --git a/tests/unit/gui/test_schema_form.py b/tests/unit/gui/test_schema_form.py index a997a45..289d1cd 100644 --- a/tests/unit/gui/test_schema_form.py +++ b/tests/unit/gui/test_schema_form.py @@ -84,3 +84,111 @@ def test_enum_default_not_in_choices_falls_back_to_first(qtbot): build_schema_widgets(layout, schema, out) widget, ftype = out["mode"] assert read_schema_widget(widget, ftype) == "full" + + +# --- the ble_address field type ----------------------------------------------- + + +def _ble_form(qtbot, run_async=None, default=""): + """Render a one-field schema containing a BLE address.""" + from PyQt6.QtWidgets import QFormLayout, QWidget + + from glider.gui.widgets.schema_form import build_schema_widgets + + host = QWidget() + qtbot.addWidget(host) + layout = QFormLayout(host) + out: dict = {} + build_schema_widgets( + layout, + [{"key": "address", "label": "Address", "type": "ble_address", "default": default}], + out, + run_async=run_async, + ) + return host, out + + +def test_a_ble_address_field_offers_scan_when_it_can_scan(qtbot): + """A plugin BLE device gets the same Scan button the built-ins have, + without the hardware panel special-casing it by name.""" + from PyQt6.QtWidgets import QPushButton + + host, out = _ble_form(qtbot, run_async=lambda coro: coro.close()) + + assert "address" in out + assert [b.text() for b in host.findChildren(QPushButton)] == ["Scan"] + + +def test_it_degrades_to_a_typeable_field_without_a_runner(qtbot): + """Scanning is async. A form with no loop to hand still has to render -- + an address can always be typed.""" + from PyQt6.QtWidgets import QPushButton + + host, out = _ble_form(qtbot, run_async=None) + + assert host.findChildren(QPushButton) == [] + widget, ftype = out["address"] + assert widget.isEditable() + assert ftype == "ble_address" + + +def test_clicking_scan_runs_the_scan_coroutine(qtbot): + from PyQt6.QtWidgets import QPushButton + + started = [] + + def _runner(coro): + started.append(coro) + coro.close() # don't actually touch the BLE stack in a test + + host, _out = _ble_form(qtbot, run_async=_runner) + host.findChildren(QPushButton)[0].click() + + assert started, "the Scan button did not run anything" + + +def test_a_typed_address_reads_back(qtbot): + from glider.gui.widgets.schema_form import read_schema_widget + + _host, out = _ble_form(qtbot) + widget, ftype = out["address"] + widget.setCurrentText("AA:BB:CC:DD:EE:FF") + + assert read_schema_widget(widget, ftype) == "AA:BB:CC:DD:EE:FF" + + +def test_a_scanned_entry_reads_back_its_address_not_its_label(qtbot): + """A scan lists peripherals by advertised name; the address is item data.""" + from glider.gui.widgets.schema_form import read_schema_widget + + _host, out = _ble_form(qtbot) + widget, ftype = out["address"] + widget.addItem("Maimu-01", "11:22:33:44:55:66") + widget.setCurrentIndex(widget.count() - 1) + + assert read_schema_widget(widget, ftype) == "11:22:33:44:55:66" + + +def test_a_pasted_scan_label_is_stripped(qtbot): + from glider.gui.widgets.schema_form import read_schema_widget + + _host, out = _ble_form(qtbot) + widget, ftype = out["address"] + widget.setCurrentText("AA:BB:CC:DD:EE:FF (Maimu-01)") + + assert read_schema_widget(widget, ftype) == "AA:BB:CC:DD:EE:FF" + + +def test_a_saved_address_is_shown(qtbot): + _host, out = _ble_form(qtbot, default="AA:BB:CC:DD:EE:FF") + + assert out["address"][0].currentText() == "AA:BB:CC:DD:EE:FF" + + +def test_an_empty_field_reads_back_empty(qtbot): + from glider.gui.widgets.schema_form import read_schema_widget + + _host, out = _ble_form(qtbot) + widget, ftype = out["address"] + + assert read_schema_widget(widget, ftype) == ""