Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 13 additions & 61 deletions src/glider/gui/panels/hardware_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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):
Expand Down
98 changes: 97 additions & 1 deletion src/glider/gui/widgets/schema_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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.

Expand Down Expand Up @@ -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)
Expand All @@ -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`.

Expand All @@ -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()
108 changes: 108 additions & 0 deletions tests/unit/gui/test_schema_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) == ""
Loading