Discover sensor bundles from the SDK catalog, not a product list

The picker listed a hard-coded product list and treated any *.json beside sensor.json as a
configuration, so a folder of stray JSON could be offered as a sensor. Discovery now scans the
shipped root and a writable user root beside it, keeps the SDK's verdict for every entry
(listing rejected ones with their reason) and flags duplicate profile_ids instead of shadowing
them; verified by shared/tests and apps/studio/tests.
This commit is contained in:
ykcheungab
2026-10-05 09:30:04 +08:00
parent a92a2ba237
commit 25ccffd814
11 changed files with 1513 additions and 43 deletions
@@ -59,6 +59,12 @@ class SensorDialog(QDialog):
self.axes_label.setObjectName("panelMeta")
self.config_label = ShrinkableLabel()
self.config_label.setObjectName("panelMeta")
#: What the catalog says the selected configuration *is*: family, profile, origin and
#: the SDK's verdict — plus, when it must not be started, why. The picker can only fit
#: a label on a 30 px rail; the reason needs this room.
self.profile_label = ShrinkableLabel()
self.profile_label.setObjectName("panelMeta")
self.profile_label.setWordWrap(True)
for label in (self.version_label, self.axes_label):
root.addWidget(label)
config_row = QHBoxLayout()
@@ -69,6 +75,7 @@ class SensorDialog(QDialog):
config_row.addWidget(self.btn_reveal)
config_row.addStretch(1)
root.addLayout(config_row)
root.addWidget(self.profile_label)
root.addWidget(self._section_rule())
self.export_title = QLabel()
@@ -145,6 +152,7 @@ class SensorDialog(QDialog):
f"{Path(config_ref).name if config_ref else t('status.config_none')}"
)
self.btn_reveal.setEnabled(bool(config_ref))
self._refresh_profile()
export = str(getattr(window, "_last_calibration_export", "") or "")
if not export:
@@ -179,6 +187,33 @@ class SensorDialog(QDialog):
)
self.btn_apply.setEnabled(True)
def _refresh_profile(self) -> None:
"""State the selected catalog entry, and the reason when it cannot be started.
Read from the picker rather than re-derived here, so the window and the dialog can never
say two different things about one bundle.
"""
read_entry = getattr(self.picker, "selected_entry", None)
entry = read_entry() if callable(read_entry) else None
if entry is None:
self.profile_label.setText("")
self.profile_label.setToolTip("")
return
line = t(
"sensor.catalog_profile",
family=entry.product_family or t("sensor.family_unidentified"),
profile_id=entry.profile_id or entry.folder,
origin=entry.origin,
state=entry.state,
)
blocked = entry.start_blocked_reason
if blocked:
# In the label, not only the tooltip: a refused configuration has to say why without
# being hovered over, and this is the surface that has room for the sentence.
line = f"{line}\n{t('sensor.catalog_blocked', reason=blocked)}"
self.profile_label.setText(line)
self.profile_label.setToolTip(entry.tooltip())
def retranslate(self) -> None:
self.setWindowTitle(t("sensor.dialog_title"))
self.calib_title.setText(t("sensor.calib_section"))
+110 -11
View File
@@ -5,12 +5,26 @@ selected product, then the product/sensor choice and the config file that
belongs to it. The order matters — picking ``box`` / ``mini`` / ``finger`` is
what decides which configs exist, so the two sit next to each other with their
own labels and tooltips instead of a bare "配置" combo.
Both combos are filled from the configuration catalog
(:mod:`orisys_app_shared.sensors`), not from a hard-coded product list: the type
combo offers the families bundles actually exist for, and the config combo offers
the catalog's entries for the chosen family — each labelled with its profile,
origin and the SDK's own verdict. An entry the SDK rejects stays listed, because
that is how its reason reaches the operator, and
:meth:`SensorPickerWidget.start_blocked_reason` states why the current selection
must not be started (see also :meth:`selected_entry`).
"""
from __future__ import annotations
from orisys_app_shared.i18n import t
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
from orisys_app_shared.sensors import (
FAMILY_UNIDENTIFIED,
CatalogEntry,
SensorCatalog,
scan_sensor_catalog,
)
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox,
@@ -26,6 +40,10 @@ TYPE_WIDTH = 76
CONFIG_WIDTH = 120
BADGE_WIDTH = 46
#: The badge for a family group that is not a product at all. Short on purpose: the rail is
#: sized for product names, and "?" states "not a product model" better than a truncated word.
UNIDENTIFIED_BADGE = "?"
class SensorPickerWidget(QWidget):
sensor_type_changed = Signal(str)
@@ -37,6 +55,11 @@ class SensorPickerWidget(QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(6)
#: The catalog this picker lists. Rescanned by :meth:`reload_catalog`.
self._catalog: SensorCatalog = scan_sensor_catalog()
#: The entries behind the config items, by their selection key.
self._entries_by_key: dict[str, CatalogEntry] = {}
#: The product badge that used to sit above the combos. Kept as an
#: attribute: it states which sensor the whole window is configured for.
self.thumb_label = QLabel()
@@ -54,9 +77,10 @@ class SensorPickerWidget(QWidget):
self.type_combo = QComboBox()
self.type_combo.setMinimumWidth(TYPE_WIDTH)
self.type_combo.setProperty("rail", "true")
self.type_combo.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
for name in list_sensor_types():
self.type_combo.addItem(name, name)
self.type_combo.setSizePolicy(
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed
)
self._reload_families()
self.type_combo.currentIndexChanged.connect(self._on_type_index_changed)
self.config_label = QLabel()
@@ -64,7 +88,9 @@ class SensorPickerWidget(QWidget):
self.config_combo = QComboBox()
self.config_combo.setMinimumWidth(CONFIG_WIDTH)
self.config_combo.setProperty("rail", "true")
self.config_combo.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
self.config_combo.setSizePolicy(
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed
)
self.config_combo.currentIndexChanged.connect(self._on_config_index_changed)
layout.addWidget(self.thumb_label)
@@ -77,6 +103,7 @@ class SensorPickerWidget(QWidget):
self._refresh_thumb()
self.retranslate()
# ------------------------------------------------------------------ selection
def sensor_type(self) -> str:
data = self.type_combo.currentData()
return str(data or self.type_combo.currentText() or "box")
@@ -87,6 +114,26 @@ class SensorPickerWidget(QWidget):
return str(self.config_combo.currentText() or "")
return str(data)
def selected_entry(self) -> CatalogEntry | None:
"""The catalog entry behind the current configuration, when it is one."""
return self._entries_by_key.get(self.config_name())
def start_blocked_reason(self) -> str | None:
"""Why the selected configuration must not be started; ``None`` when it may be.
The verdict is the catalog's: it is non-``None`` for an entry the SDK's validation
rejected and for one whose ``profile_id`` another bundle also declares. The entry stays
listed and explainable either way.
"""
return self._catalog.start_blocked(self.config_name())
def reload_catalog(self) -> None:
"""Rescan both roots, so a bundle added or removed since start-up appears."""
self._catalog = scan_sensor_catalog()
self._reload_families()
self._reload_configs()
self._refresh_thumb()
def set_sensor_type(self, sensor_type: str) -> None:
wanted = str(sensor_type or "box")
idx = self.type_combo.findData(wanted)
@@ -110,24 +157,60 @@ class SensorPickerWidget(QWidget):
self.config_combo.blockSignals(True)
self.config_combo.setCurrentIndex(max(0, idx))
self.config_combo.blockSignals(False)
self._refresh_config_tooltip()
def set_picker_enabled(self, enabled: bool) -> None:
"""Enable/disable both pickers (used while a session is running)."""
self.type_combo.setEnabled(bool(enabled))
self.config_combo.setEnabled(bool(enabled))
# ------------------------------------------------------------------ contents
def _reload_families(self) -> None:
"""Offer the families bundles exist for, the unidentified group last."""
current = self.type_combo.currentData()
self.type_combo.blockSignals(True)
self.type_combo.clear()
for family in self._catalog.families():
self.type_combo.addItem(_family_label(family), family)
if current is not None:
idx = self.type_combo.findData(current)
if idx >= 0:
self.type_combo.setCurrentIndex(idx)
self.type_combo.blockSignals(False)
def _reload_configs(self) -> None:
family = self.sensor_type()
entries = self._catalog.entries_for_family(family)
self._entries_by_key = {entry.key: entry for entry in entries}
self.config_combo.blockSignals(True)
self.config_combo.clear()
for display, key in list_configs_for_type(self.sensor_type()):
self.config_combo.addItem(display, key)
if self.config_combo.count() == 0:
self.config_combo.addItem(self.sensor_type(), self.sensor_type())
for entry in entries:
self.config_combo.addItem(_entry_label(entry), entry.key)
self.config_combo.setItemData(
self.config_combo.count() - 1,
entry.tooltip(),
Qt.ItemDataRole.ToolTipRole,
)
if not entries:
# No bundle for this family at all: keep the single fallback entry the old catalog
# offered, so a preference (or a future Run) still has something to name.
self.config_combo.addItem(family, family)
self.config_combo.blockSignals(False)
self._refresh_config_tooltip()
def _refresh_config_tooltip(self) -> None:
entry = self.selected_entry()
self.config_combo.setToolTip(
entry.tooltip() if entry is not None else t("sensor.config_tip")
)
def _refresh_thumb(self) -> None:
self.thumb_label.setText(self.sensor_type().upper())
family = self.sensor_type()
self.thumb_label.setText(
UNIDENTIFIED_BADGE if family == FAMILY_UNIDENTIFIED else family.upper()
)
# ------------------------------------------------------------------ signals
def _on_type_index_changed(self, _index: int) -> None:
self._reload_configs()
self._refresh_thumb()
@@ -135,6 +218,7 @@ class SensorPickerWidget(QWidget):
self.config_changed.emit(self.config_name())
def _on_config_index_changed(self, _index: int) -> None:
self._refresh_config_tooltip()
self.config_changed.emit(self.config_name())
def retranslate(self) -> None:
@@ -143,6 +227,21 @@ class SensorPickerWidget(QWidget):
self.type_label.setToolTip(t("sensor.type_tip"))
self.config_label.setToolTip(t("sensor.config_tip"))
self.type_combo.setToolTip(t("sensor.type_tip"))
self.config_combo.setToolTip(t("sensor.config_tip"))
self.thumb_label.setToolTip(t("sensor.thumb_placeholder"))
self._refresh_thumb()
self._refresh_config_tooltip()
def _family_label(family: str) -> str:
"""A family as the operator reads it; the unidentified group is not a product name."""
return t("sensor.family_unidentified") if family == FAMILY_UNIDENTIFIED else family
def _entry_label(entry: CatalogEntry) -> str:
"""One combo item: the catalog's label with its family group named in this locale.
The catalog's own label is the data form (and is what a log or a test should compare); the
internal marker for "no product family" is replaced here, where translating belongs, so an
operator never reads a sentinel.
"""
return entry.label().replace(FAMILY_UNIDENTIFIED, t("sensor.family_unidentified"))