feat(shell): add CablePanel for dashboard cable selection
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
126
cim_suite/shell/cable_panel.py
Normal file
126
cim_suite/shell/cable_panel.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Dashboard cable panel: shows detected service cables and resolves the active one.
|
||||
|
||||
Rules: the simulator entry (if present) is the default selection; otherwise a lone
|
||||
port auto-selects; several ports force a deliberate pick (nothing pre-selected).
|
||||
Selection is published via ``sourceChanged`` and read via ``active_source()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from cim_suite.core.transport.port_scan import SIMULATOR, DetectedPort, scan_ports
|
||||
from cim_suite.core.ui.theme import Space
|
||||
|
||||
|
||||
class CablePanel(QFrame):
|
||||
sourceChanged = Signal(str) # active source device, or "" when none selected
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
include_simulator: bool = False,
|
||||
scan_fn: Callable[..., list[DetectedPort]] = scan_ports,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("CablePanel")
|
||||
self._include_sim = include_simulator
|
||||
self._scan_fn = scan_fn
|
||||
self._active = ""
|
||||
self._group = QButtonGroup(self)
|
||||
self._group.setExclusive(True)
|
||||
|
||||
title = QLabel("Service cable")
|
||||
title.setObjectName("CablePanelTitle")
|
||||
self._rescan_btn = QPushButton("⟳ Rescan")
|
||||
self._rescan_btn.clicked.connect(self.rescan)
|
||||
self._hint = QLabel("")
|
||||
self._hint.setObjectName("CablePanelHint")
|
||||
self._hint.setWordWrap(True)
|
||||
|
||||
header = QHBoxLayout()
|
||||
header.addWidget(title)
|
||||
header.addStretch(1)
|
||||
header.addWidget(self._rescan_btn)
|
||||
|
||||
self._rows = QVBoxLayout()
|
||||
self._rows.setSpacing(Space.XS)
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(Space.MD, Space.MD, Space.MD, Space.MD)
|
||||
outer.setSpacing(Space.SM)
|
||||
outer.addLayout(header)
|
||||
outer.addLayout(self._rows)
|
||||
outer.addWidget(self._hint)
|
||||
|
||||
def active_source(self) -> str:
|
||||
return self._active
|
||||
|
||||
def rescan(self) -> int:
|
||||
"""Re-enumerate, rebuild the rows, apply the default-selection rules.
|
||||
|
||||
Returns the number of selectable rows (so the caller can decide whether to
|
||||
show the no-cable modal).
|
||||
"""
|
||||
self._clear_rows()
|
||||
ports = self._scan_fn(include_simulator=self._include_sim)
|
||||
self._active = ""
|
||||
|
||||
default_btn = None
|
||||
for dp in ports:
|
||||
rb = QRadioButton(dp.label)
|
||||
rb.setProperty("device", dp.device)
|
||||
rb.toggled.connect(
|
||||
lambda checked, d=dp.device: self._on_pick(d) if checked else None
|
||||
)
|
||||
self._group.addButton(rb)
|
||||
self._rows.addWidget(rb)
|
||||
if dp.device == SIMULATOR:
|
||||
default_btn = rb
|
||||
if default_btn is None and len(ports) == 1:
|
||||
default_btn = self._group.buttons()[0]
|
||||
|
||||
self._set_error(len(ports) == 0)
|
||||
if default_btn is not None:
|
||||
default_btn.setChecked(True) # fires toggled -> _on_pick -> sourceChanged
|
||||
else:
|
||||
if len(ports) == 0:
|
||||
self._hint.setText(
|
||||
"No service cable detected. Plug it in and Rescan."
|
||||
)
|
||||
else:
|
||||
self._hint.setText("Select a cable to enable modules.")
|
||||
self.sourceChanged.emit("")
|
||||
return len(ports)
|
||||
|
||||
def _clear_rows(self) -> None:
|
||||
for btn in list(self._group.buttons()):
|
||||
self._group.removeButton(btn)
|
||||
while self._rows.count():
|
||||
item = self._rows.takeAt(0)
|
||||
w = item.widget()
|
||||
if w is not None:
|
||||
w.deleteLater()
|
||||
|
||||
def _on_pick(self, device: str) -> None:
|
||||
self._active = device
|
||||
self._hint.setText("")
|
||||
self.sourceChanged.emit(device)
|
||||
|
||||
def _set_error(self, is_error: bool) -> None:
|
||||
self.setProperty("state", "error" if is_error else "ok")
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
65
tests/shell/test_cable_panel.py
Normal file
65
tests/shell/test_cable_panel.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from cim_suite.core.transport.port_scan import SIMULATOR, DetectedPort
|
||||
from cim_suite.shell.cable_panel import CablePanel
|
||||
|
||||
|
||||
def _fake_scan(ports):
|
||||
return lambda *, include_simulator=False: list(ports)
|
||||
|
||||
|
||||
def test_single_port_auto_selected(qtbot):
|
||||
ports = [DetectedPort("COM5", "COM5 — service cable", True)]
|
||||
panel = CablePanel(scan_fn=_fake_scan(ports))
|
||||
qtbot.addWidget(panel)
|
||||
with qtbot.waitSignal(panel.sourceChanged) as sig:
|
||||
count = panel.rescan()
|
||||
assert count == 1
|
||||
assert panel.active_source() == "COM5"
|
||||
assert sig.args == ["COM5"]
|
||||
|
||||
|
||||
def test_multiple_ports_force_a_pick(qtbot):
|
||||
ports = [
|
||||
DetectedPort("COM5", "COM5 — service cable", True),
|
||||
DetectedPort("COM6", "COM6 — service cable", True),
|
||||
]
|
||||
panel = CablePanel(scan_fn=_fake_scan(ports))
|
||||
qtbot.addWidget(panel)
|
||||
count = panel.rescan()
|
||||
assert count == 2
|
||||
assert panel.active_source() == "" # nothing pre-selected
|
||||
|
||||
|
||||
def test_picking_a_radio_updates_active_source(qtbot):
|
||||
ports = [
|
||||
DetectedPort("COM5", "COM5 — service cable", True),
|
||||
DetectedPort("COM6", "COM6 — service cable", True),
|
||||
]
|
||||
panel = CablePanel(scan_fn=_fake_scan(ports))
|
||||
qtbot.addWidget(panel)
|
||||
panel.rescan()
|
||||
# Find the COM6 radio and check it.
|
||||
radio = next(b for b in panel._group.buttons() if b.property("device") == "COM6")
|
||||
with qtbot.waitSignal(panel.sourceChanged) as sig:
|
||||
radio.setChecked(True)
|
||||
assert panel.active_source() == "COM6"
|
||||
assert sig.args == ["COM6"]
|
||||
|
||||
|
||||
def test_simulator_is_preselected_even_with_real_ports(qtbot):
|
||||
ports = [
|
||||
DetectedPort(SIMULATOR, "Simulator (no hardware)", False),
|
||||
DetectedPort("COM5", "COM5 — service cable", True),
|
||||
]
|
||||
panel = CablePanel(scan_fn=_fake_scan(ports), include_simulator=True)
|
||||
qtbot.addWidget(panel)
|
||||
panel.rescan()
|
||||
assert panel.active_source() == SIMULATOR
|
||||
|
||||
|
||||
def test_zero_ports_sets_error_state(qtbot):
|
||||
panel = CablePanel(scan_fn=_fake_scan([]))
|
||||
qtbot.addWidget(panel)
|
||||
count = panel.rescan()
|
||||
assert count == 0
|
||||
assert panel.active_source() == ""
|
||||
assert panel.property("state") == "error"
|
||||
Reference in New Issue
Block a user