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:
2026-06-02 13:16:40 -04:00
parent f3f60d21b8
commit 2972c4e264
2 changed files with 191 additions and 0 deletions

View 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)

View 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"