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
+4 -1
View File
@@ -179,7 +179,10 @@
"export_none": "No coefficients exported yet",
"export_file": "file {name} ({size} KB, {time})",
"export_identity": "file identity type {sensor_type} · SN {sn} · config {config}",
"thumb_placeholder": "Product model"
"thumb_placeholder": "Product model",
"family_unidentified": "Unidentified",
"catalog_profile": "Profile {profile_id} · family {family} · from {origin} · {state}",
"catalog_blocked": "Cannot start: {reason}"
},
"panels": {
"arrow": "2D image",
+4 -1
View File
@@ -179,7 +179,10 @@
"export_none": "还没有导出过系数",
"export_file": "文件 {name}({size} KB,{time})",
"export_identity": "文件身份 类型 {sensor_type} · SN {sn} · 配置 {config}",
"thumb_placeholder": "产品型号"
"thumb_placeholder": "产品型号",
"family_unidentified": "未识别",
"catalog_profile": "配置档案 {profile_id} · 家族 {family} · 来源 {origin} · 状态 {state}",
"catalog_blocked": "无法启动:{reason}"
},
"panels": {
"arrow": "2D 图像",
@@ -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"))
@@ -0,0 +1,129 @@
"""The picker lists what the catalog returns, including what it refuses.
This is the selector side of the catalog contract: an entry the SDK rejects must still be
listed (that is how its reason reaches the operator), a duplicate ``profile_id`` must be listed
twice with both sides named, and ``start_blocked_reason()`` must be the answer a caller can
refuse to start on. The catalog itself is tested in ``shared/tests``; here it is a fixture tree
pointed at the widget, so the assertions are about the combo contents rather than the
filesystem.
"""
from __future__ import annotations
import json
from collections.abc import Callable
from pathlib import Path
import pytest
from orisys_app_shared.sensors import FAMILY_UNIDENTIFIED, scan_sensor_catalog
from orisys_studio.ui import sensor_picker as picker_module
from orisys_studio.ui.sensor_picker import SensorPickerWidget
from PySide6.QtCore import Qt
CONFIG = {
"name": "fixture",
"camera": {
"resolution_raw": [640, 480],
"fps": 30,
"resolution_stitch": [100, 100],
},
"tracker": {"algorithm": "dis"},
}
def _write(path: Path, payload: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def _descriptor(profile_id: str) -> dict[str, object]:
return {
"schema_version": 1,
"profile_id": profile_id,
"display_name": "Mini lab",
"product_family": "mini",
"processing_backend": "single_camera",
"sensor_json": "sensor.json",
}
def _make_picker(qt_app, tmp_path, monkeypatch, build: Callable[[Path], None]):
"""A picker whose catalog is a fixture tree instead of the installed one."""
root = tmp_path / "user"
build(root)
catalog = scan_sensor_catalog(
shipped_root=tmp_path / "no-shipped-root", user_root=root
)
monkeypatch.setattr(picker_module, "scan_sensor_catalog", lambda: catalog)
return SensorPickerWidget()
@pytest.fixture
def mixed_picker(qt_app, tmp_path, monkeypatch):
"""Two bundles sharing a profile_id, plus a folder holding only a calibration."""
def build(root: Path) -> None:
for name in ("mini-a", "mini-b"):
_write(root / name / "sensor.json", CONFIG)
_write(root / name / "manifest.json", _descriptor("orisys.mini.same-r1"))
_write(root / "stray" / "force_calibration.json", {"schema_version": 2})
widget = _make_picker(qt_app, tmp_path, monkeypatch, build)
yield widget
widget.deleteLater()
def test_a_calibration_folder_is_listed_as_invalid_with_its_reason(mixed_picker):
picker = mixed_picker
assert picker.type_combo.findData(FAMILY_UNIDENTIFIED) >= 0
picker.set_sensor_type(FAMILY_UNIDENTIFIED)
assert picker.config_combo.count() == 1
entry = picker.selected_entry()
assert entry is not None and not entry.valid
# The internal "no product family" marker is named in the operator's language, not leaked.
assert FAMILY_UNIDENTIFIED not in picker.config_combo.itemText(0)
tooltip = picker.config_combo.itemData(0, Qt.ItemDataRole.ToolTipRole)
assert "force_calibration.json" in tooltip
assert "sensor.json or a manifest.json descriptor" in tooltip
assert picker.start_blocked_reason() == entry.start_blocked_reason
def test_a_shared_profile_id_is_listed_twice_and_can_be_refused(mixed_picker):
picker = mixed_picker
picker.set_sensor_type("mini")
assert picker.config_combo.count() == 2, "one of the duplicates was shadowed"
first = picker.selected_entry()
picker.config_combo.setCurrentIndex(1)
second = picker.selected_entry()
assert first is not None and second is not None
assert {first.profile_id, second.profile_id} == {"orisys.mini.same-r1"}
assert {first.folder, second.folder} == {"mini-a", "mini-b"}
# Each side names the other, and the verdict follows the *selected* entry.
for index, other_ref in ((0, "user/mini-b"), (1, "user/mini-a")):
picker.config_combo.setCurrentIndex(index)
entry = picker.selected_entry()
assert entry is not None and entry.duplicates == (other_ref,)
reason = picker.start_blocked_reason()
assert reason is not None and other_ref in reason
# Both stay selectable: the list shows the ambiguity instead of resolving it silently.
assert picker.config_combo.count() == 2
def test_a_single_valid_entry_is_listed_with_its_identity_and_not_blocked(
qt_app, tmp_path, monkeypatch
):
def build(root: Path) -> None:
_write(root / "mini-lab" / "sensor.json", CONFIG)
_write(root / "mini-lab" / "manifest.json", _descriptor("orisys.mini.lab-r1"))
picker = _make_picker(qt_app, tmp_path, monkeypatch, build)
assert picker.type_combo.findData("mini") >= 0
picker.set_sensor_type("mini")
label = picker.config_combo.itemText(0)
assert "orisys.mini.lab-r1" in label
assert "mini" in label and "user" in label and "degraded" in label
assert picker.start_blocked_reason() is None
entry = picker.selected_entry()
assert entry is not None and entry.profile_id == "orisys.mini.lab-r1"
picker.deleteLater()
@@ -0,0 +1,271 @@
"""The catalog against the configuration the studio actually ships, and the rename contract.
The first half asserts what ``apps/studio/config/sensors`` contains *today*, entry by entry, so
a change to the shipped tree shows up here instead of only on a machine that happens to have
the right folders.
``finger`` is the one shipped bundle that carries a descriptor, so its identity is the profile
the descriptor declares instead of one derived from its folder name. Its declared asset paths
are asserted to be the files in the tree, because a rename that silently resolved elsewhere is
exactly the failure the descriptor exists to prevent.
The second half is the rename contract: a bundle keeps its identity when its folder is renamed.
That is true because every descriptor path is relative to the bundle root, so it belongs to
discovery — the catalog is where a renamed bundle is compared with the one it was copied from.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
import pytest
from orisys.sensors import DescriptorError, resolve_bundle
from orisys.unroll import scaled_map_cache_path
from orisys_app_shared.acquisition.finger_worker import resolve_finger_export_dir
from orisys_app_shared.paths import sensors_dir
from orisys_app_shared.sensors import (
ORIGIN_SHIPPED,
ROOT_KIND_FALLBACK,
ROOT_KIND_SIBLING,
scan_sensor_catalog,
)
#: The identity the shipped finger descriptor declares. A copy of the bundle keeps it.
FINGER_PROFILE_ID = "orisys.finger.r01"
#: What the shipped root provides today, as the SDK resolves it.
EXPECTED_SHIPPED: dict[str, tuple[str, str, str, bool, str]] = {
# folder: (family, profile_id, backend, valid, state)
"box": ("box", "orisys.box.legacy", "compound_eye", True, "degraded"),
"finger": ("finger", FINGER_PROFILE_ID, "uv_unroll", True, "degraded"),
"finger-mems": (
"finger-mems",
"orisys.finger-mems.legacy",
"compound_eye",
True,
"degraded",
),
"mini": ("mini", "orisys.mini.legacy", "compound_eye", True, "degraded"),
}
@pytest.fixture(scope="module")
def shipped():
return scan_sensor_catalog()
def test_the_shipped_root_is_the_one_the_app_resolves(shipped):
assert shipped.shipped_root == sensors_dir()
assert shipped.roots[0].exists is True
def test_the_shipped_root_offers_exactly_its_four_bundles(shipped):
refs = {entry.ref for entry in shipped.entries_for_origin(ORIGIN_SHIPPED)}
assert refs == {
"shipped/box",
"shipped/finger",
"shipped/finger-mems",
"shipped/mini",
}, sorted(refs)
def test_every_shipped_entry_reports_its_family_profile_and_verdict(shipped):
for folder, (family, profile_id, backend, valid, state) in EXPECTED_SHIPPED.items():
entry = shipped.by_ref(f"shipped/{folder}")
assert entry is not None, folder
assert entry.product_family == family, folder
assert entry.profile_id == profile_id, folder
assert entry.processing_backend == backend, folder
assert entry.valid is valid, f"{folder}: {entry.reason}"
assert entry.state == state, f"{folder}: {entry.reason}"
assert entry.origin == ORIGIN_SHIPPED
# The verdict is the SDK's, not a second opinion computed here.
assert (entry.start_blocked_reason is None) == (
valid and not entry.duplicate
), folder
def test_the_shipped_families_keep_the_picker_order(shipped):
assert shipped.families() == ["box", "mini", "finger", "finger-mems"]
def test_box_mini_and_finger_mems_are_legacy_sensor_json_bundles(shipped):
for folder in ("box", "mini", "finger-mems"):
bundle = sensors_dir() / folder
assert (bundle / "sensor.json").is_file(), folder
assert not (bundle / "manifest.json").exists(), folder
entry = shipped.by_ref(f"shipped/{folder}")
assert entry.profile is not None and entry.profile.sensor_json_path.is_file(), (
folder
)
def test_the_finger_bundle_ships_a_descriptor_naming_the_files_it_has(shipped):
"""The shipped ``finger/manifest.json`` is a *descriptor*, not the legacy shape.
Its identity is declared rather than inferred from the folder name, and every path it
declares is relative to the bundle root. The declared files are asserted to exist, so
renaming a shipped map or scene without updating the descriptor fails here.
"""
bundle = sensors_dir() / "finger"
manifest = json.loads((bundle / "manifest.json").read_text(encoding="utf-8"))
assert manifest["schema_version"] == 1, "the descriptor contract is version 1"
assert manifest["profile_id"] == FINGER_PROFILE_ID
assert manifest["display_name"]
assert manifest["product_family"] == "finger"
assert manifest["processing_backend"] == "uv_unroll"
assert manifest["sensor_json"] == "sensor.json"
declared = [
manifest["sensor_json"],
*manifest["computational_assets"].values(),
*manifest["assets"].values(),
]
assert declared, "a descriptor that names no file describes nothing"
for relative in declared:
assert not Path(relative).is_absolute(), relative
assert ".." not in Path(relative).parts, relative
assert (bundle / relative).is_file(), f"declared but absent: {relative}"
entry = shipped.by_ref("shipped/finger")
assert entry.profile is not None
assert entry.profile.sensor_json_path == bundle / "sensor.json"
assert entry.valid and entry.start_blocked_reason is None, entry.reason
# The verdict is the SDK's, and the undeclared-asset failure is gone for good.
assert "assets.computational" not in entry.reason, entry.reason
def test_the_declared_assets_are_the_files_the_running_chain_loads(shipped):
"""The descriptor's asset paths and the live chain agree, file name included.
The finger chain builds its UV map cache name for itself, from the export directory and
``camera.source_scale`` (the SDK's ``scaled_map_cache_path``). That derivation and the
descriptor are two statements of the same fact, so they are compared here: a bundle whose
map was renamed would otherwise keep resolving to the name the chain still asks for.
"""
bundle = sensors_dir() / "finger"
profile = shipped.by_ref("shipped/finger").profile
assert profile is not None
export_dir = resolve_finger_export_dir(profile.sensor_json_path)
assert export_dir == bundle / "assets" / "30fps-0703"
chain_map = Path(
scaled_map_cache_path(
str(export_dir / "unroll_map_uv.npz"), profile.config["camera"]["source_scale"]
)
)
declared_map = profile.computational_assets["unroll_map"]
assert declared_map == chain_map
# The scale suffix is the configuration's (0.42), never a neighbouring variant.
assert declared_map.name == "unroll_map_uv_s0.42.npz"
assert declared_map.is_file()
assert not (export_dir / "unroll_map_uv_s0.4.npz").exists()
declared_scene = profile.assets["scene"]
assert declared_scene == bundle / "scene" / "finger-display-1.0.0.oryscene"
assert declared_scene.is_file()
assert profile.capabilities.visualization_3d.available
def test_a_calibration_file_is_never_offered_as_a_configuration(shipped):
"""``box/`` holds ``sensor.json`` and ``force_calibration.json``: one entry, not two."""
box = sensors_dir() / "box"
assert (box / "force_calibration.json").is_file(), "the premise of this test"
entries = shipped.entries_for_family("box")
assert len(entries) == 1, [entry.key for entry in entries]
assert Path(entries[0].key).name == "sensor.json"
for entry in shipped.entries:
assert Path(entry.key).name != "force_calibration.json"
assert "force_calibration" not in entry.folder
def test_the_user_root_is_absent_today_and_that_is_not_an_error(shipped):
"""Nothing is offered from a user root that does not exist, and no error is raised.
W2 will import bundles into that root; when it does, this assertion is the one to revisit —
the point being that an absent root is empty, not broken.
"""
assert shipped.user_root_kind in (ROOT_KIND_SIBLING, ROOT_KIND_FALLBACK)
assert shipped.user_root_exists is False, shipped.user_root
assert shipped.entries_for_origin(shipped.roots[1].origin) == ()
# The shipped root is never the place user bundles go.
assert shipped.shipped_root != shipped.user_root
assert shipped.shipped_root not in shipped.user_root.parents
# ------------------------------------------------------------------ rename contract
def _copy_finger(destination: Path, *, descriptor: bool = True) -> Path:
"""Copy the shipped finger bundle, optionally stripping its descriptor."""
shutil.copytree(sensors_dir() / "finger", destination)
if not descriptor:
(destination / "manifest.json").unlink()
return destination
def test_a_renamed_bundle_keeps_its_identity(tmp_path):
"""The descriptor's relative paths are what make moving or renaming a bundle safe."""
root = tmp_path / "bundles"
first = _copy_finger(root / "finger-a")
second = _copy_finger(root / "somewhere-else-entirely")
assert first.name != second.name
catalog = scan_sensor_catalog(
shipped_root=tmp_path / "no-shipped-root", user_root=root
)
a = catalog.by_ref("user/finger-a")
b = catalog.by_ref("user/somewhere-else-entirely")
assert a is not None and b is not None
assert a.valid and b.valid, (a.reason, b.reason)
assert a.profile_id == b.profile_id == FINGER_PROFILE_ID
assert a.product_family == b.product_family == "finger"
assert a.processing_backend == b.processing_backend == "uv_unroll"
assert a.display_name == b.display_name
assert a.profile.processing_fingerprint == b.profile.processing_fingerprint
# The reproducible snapshot is bundle-relative, so the folder name cannot appear in it.
assert a.profile.inventory() == b.profile.inventory()
inventory = a.profile.inventory()
assert inventory["sensor_config"] == "sensor.json"
assert inventory["computational_assets"]["unroll_map"]["path"] == (
"assets/30fps-0703/unroll_map_uv_s0.42.npz"
)
assert inventory["visualization_assets"]["scene"] == (
"scene/finger-display-1.0.0.oryscene"
)
assert str(tmp_path) not in json.dumps(inventory)
# Every resolved path stays inside the copy it was resolved from.
for entry, copy in ((a, first), (b, second)):
inside = copy.resolve()
assert entry.profile.bundle_root == inside
assert entry.profile.sensor_json_path.is_relative_to(inside)
for role, path in entry.profile.computational_assets.items():
assert path.is_file(), role
assert path.is_relative_to(inside), path
for role, path in entry.profile.assets.items():
assert path.is_relative_to(inside), path
# One profile, two bundles: the catalog names the twin instead of picking one silently.
assert a.key != b.key
assert a.duplicates == ("user/somewhere-else-entirely",)
assert b.duplicates == ("user/finger-a",)
assert a.start_blocked_reason is not None
def test_the_same_copy_without_a_descriptor_is_refused_by_its_folder_name(tmp_path):
"""The negative control: rename a *legacy* bundle and it is no longer identifiable."""
plain = _copy_finger(tmp_path / "finger-a", descriptor=False)
with pytest.raises(DescriptorError) as failure:
resolve_bundle(plain)
assert "not a built-in bundle" in str(failure.value)
catalog = scan_sensor_catalog(
shipped_root=tmp_path / "no-shipped-root", user_root=tmp_path
)
entry = catalog.by_ref("user/finger-a")
assert entry is not None, "a bundle that cannot be resolved is still listed"
assert not entry.valid
assert "not a built-in bundle" in entry.reason
assert entry.start_blocked_reason == entry.reason
+28
View File
@@ -92,6 +92,34 @@ def sensors_dir() -> Path:
return config_dir() / "sensors"
def user_sensors_dir() -> Path:
"""The writable user bundle root: a **sibling** of the shipped one.
The app ships as a portable folder, so the natural place for a bundle the
operator adds is beside the shipped ``config/sensors`` rather than inside it:
an upgrade that replaces the shipped tree must not delete their work, and a
zip that is unpacked somewhere else must carry it along.
This is only the preferred location. Whether it can be written — and where
the catalog goes when it cannot — is decided by
:func:`orisys_app_shared.sensors.bundle_catalog.resolve_user_root`, which
reports the root actually in use. Never place user bundles in the shipped
root, and never in Documents.
"""
return config_dir() / "sensors-user"
def fallback_user_sensors_dir() -> Path:
"""The per-user bundle root used when the portable sibling is not writable.
``%LOCALAPPDATA%\\OrisysStudio\\config\\sensors-user`` on Windows, and the
equivalent cache root elsewhere — the same derived-data area
:func:`derived_data_dir` already owns, so a read-only installation medium
still has somewhere writable to keep the operator's bundles.
"""
return derived_data_dir() / "config" / "sensors-user"
def locales_dir() -> Path:
return config_dir() / "locales"
@@ -1,5 +1,45 @@
"""Sensor catalog helpers."""
"""Sensor catalog helpers.
The catalog answers three questions the picker and the resolver both need: which configuration
bundles exist, what each one is (family, profile, origin and the SDK's verdict), and where a
user's own bundles go on this machine.
"""
from orisys_app_shared.sensors.bundle_catalog import (
FAMILY_UNIDENTIFIED,
KNOWN_FAMILY_ORDER,
ORIGIN_FALLBACK,
ORIGIN_SHIPPED,
ORIGIN_USER,
ROOT_KIND_EXPLICIT,
ROOT_KIND_FALLBACK,
ROOT_KIND_SIBLING,
STATE_UNREADABLE,
CatalogEntry,
CatalogRoot,
SensorCatalog,
UserRootDecision,
resolve_user_root,
scan_sensor_catalog,
)
from orisys_app_shared.sensors.catalog import list_configs_for_type, list_sensor_types
__all__ = ["list_configs_for_type", "list_sensor_types"]
__all__ = [
"FAMILY_UNIDENTIFIED",
"KNOWN_FAMILY_ORDER",
"ORIGIN_FALLBACK",
"ORIGIN_SHIPPED",
"ORIGIN_USER",
"ROOT_KIND_EXPLICIT",
"ROOT_KIND_FALLBACK",
"ROOT_KIND_SIBLING",
"STATE_UNREADABLE",
"CatalogEntry",
"CatalogRoot",
"SensorCatalog",
"UserRootDecision",
"list_configs_for_type",
"list_sensor_types",
"resolve_user_root",
"scan_sensor_catalog",
]
@@ -0,0 +1,567 @@
"""The sensor configuration catalog: which bundles exist, and what each one is.
Studio used to offer a hard-coded product list and to treat any ``*.json`` beside a
``sensor.json`` as another configuration. Both are replaced here by discovery over the two
roots the app actually has:
* the **shipped** root — ``config/sensors``, resolved exactly as
:func:`orisys_app_shared.paths.sensors_dir` resolves it today;
* a **writable user** root *beside* it — ``config/sensors-user``, because the app ships as a
portable folder and the operator's own bundles belong next to it, never inside the shipped
tree and never in Documents. When that directory cannot be written (a read-only install
medium, a locked-down install directory) the OS per-user area is used instead and the
catalog says which root is in use.
Discovery is the SDK's, in this order: a parseable ``manifest.json`` descriptor, then the
compatibility adapter for a legacy ``sensor.json`` bundle, then an entry retained as
**invalid** carrying the SDK's own explanation. This module never decides a processing
backend and never reads a folder name as evidence of one; it reports what
:func:`orisys.sensors.resolve_bundle` decided.
A folder holding only an arbitrary JSON document — ``force_calibration.json`` — is not a
configuration. It is still listed, marked invalid, and its reason says so, because silently
hiding it is how the same file ends up offered as a sensor config.
Two bundles that declare one ``profile_id`` are both listed and each names the other. Nothing
is shadowed, and the ambiguity is available to a caller that must refuse to start.
"""
from __future__ import annotations
import contextlib
import json
import os
import uuid
from dataclasses import dataclass, replace
from pathlib import Path
from typing import TYPE_CHECKING, Any
from orisys_app_shared.paths import (
fallback_user_sensors_dir,
sensors_dir,
user_sensors_dir,
)
if TYPE_CHECKING: # pragma: no cover - typing only
from orisys.sensors import ResolvedSensorProfile
#: Which root an entry came from.
ORIGIN_SHIPPED = "shipped"
ORIGIN_USER = "user"
ORIGIN_FALLBACK = "fallback"
#: Which physical location the writable user root resolved to.
ROOT_KIND_SIBLING = "sibling"
ROOT_KIND_FALLBACK = "fallback"
ROOT_KIND_EXPLICIT = "explicit"
#: Family bucket for an entry that cannot be attributed to a product family. It is deliberately
#: not a product name: a folder of stray JSON has no product, and inventing one is how a
#: calibration file becomes a sensor.
FAMILY_UNIDENTIFIED = "__unidentified__"
#: The order the picker offers families in. The families the shipped tree has always provided
#: come first, in the order the picker has always used them, so replacing the hard-coded product
#: list does not reshuffle the picker for the products that exist today. This is presentation
#: order only — which families are offered comes from what is installed.
KNOWN_FAMILY_ORDER: tuple[str, ...] = ("box", "mini", "finger", "finger-mems")
#: ``CatalogEntry.state`` for a folder no bundle could be read from at all. Every other state is
#: the SDK's own :attr:`ValidationReport.state` (``ok`` / ``degraded`` / ``failed``).
STATE_UNREADABLE = "invalid"
_WRITE_PROBE_PREFIX = ".orisys-write-probe"
def _sdk():
"""The SDK surface this module needs, imported on first use.
``orisys_app_shared.paths`` already reaches for the SDK lazily, so a shared module stays
importable without it; this keeps that behaviour instead of making the SDK a hard import of
the shared sensor package.
"""
from orisys import sensors
return sensors
def _sort_key(path: Path) -> str:
return path.name.lower()
@dataclass(frozen=True)
class UserRootDecision:
"""Where the writable user bundles live, and why."""
path: Path
#: ``sibling`` (the portable ``config/sensors-user``), ``fallback`` (the OS per-user area)
#: or ``explicit`` (named by the caller, as a test does).
kind: str
#: Whether a file can actually be created there right now.
writable: bool
reason: str
def __str__(self) -> str:
return self.reason
@dataclass(frozen=True)
class CatalogRoot:
"""One directory the catalog scans."""
origin: str
path: Path
exists: bool
def __str__(self) -> str:
return f"{self.origin}: {self.path}" + ("" if self.exists else " (absent)")
@dataclass(frozen=True)
class CatalogEntry:
"""One bundle folder, as the catalog sees it.
``valid`` is the SDK's verdict (its validation report is usable), never a judgement made
here, so an entry the SDK rejects is shown with the reason instead of being dropped.
"""
#: Selection key: the configuration file the app already passes around, or the bundle folder
#: when no ``sensor.json`` could be found. Unique per entry.
key: str
folder: str
bundle_path: Path
origin: str
display_name: str
product_family: str
profile_id: str
sensor_json_path: Path | None
processing_backend: str
valid: bool
state: str
reason: str = ""
errors: tuple[str, ...] = ()
warnings: tuple[str, ...] = ()
diagnostics: tuple[str, ...] = ()
#: ``origin/folder`` of every other bundle declaring this entry's ``profile_id``.
duplicates: tuple[str, ...] = ()
profile: ResolvedSensorProfile | None = None
@property
def ref(self) -> str:
"""``origin/folder`` — readable, and unique across the two roots."""
return f"{self.origin}/{self.folder}"
@property
def family(self) -> str:
"""The family group this entry is listed under; never empty."""
return self.product_family or FAMILY_UNIDENTIFIED
@property
def duplicate(self) -> bool:
return bool(self.duplicates)
@property
def start_blocked_reason(self) -> str | None:
"""Why no session may be started with this entry, or ``None`` when one may.
The verdict belongs to the SDK: a profile its validation rejected is not startable, and
two bundles declaring one ``profile_id`` make choosing between them ambiguous. The entry
stays listed and explainable either way — that is the point of returning a reason.
"""
if not self.valid:
return self.reason or f"the SDK rejected this profile ({self.state})"
if self.duplicates:
return (
f"profile_id {self.profile_id!r} is also provided by "
f"{', '.join(self.duplicates)}; selecting between them is ambiguous"
)
return None
def label(self) -> str:
"""One line for a picker item: family, profile, origin and validity."""
return f"{self.family} · {self.profile_id or self.folder} · {self.origin} · {self.state}"
def tooltip(self) -> str:
"""The facts behind the label, including why the entry is not startable."""
lines = [
f"{self.display_name} ({self.ref})",
f"profile_id: {self.profile_id or 'unknown'}",
f"product family: {self.product_family or 'unknown'}",
f"processing backend: {self.processing_backend or 'unknown'}",
f"origin: {self.origin} ({self.bundle_path})",
f"state: {self.state}",
]
blocked = self.start_blocked_reason
if blocked:
lines.append(f"cannot start: {blocked}")
# The reason already quotes the errors it was built from; repeating them here would
# bury the one line that explains the entry.
lines.extend(
f"error: {text}" for text in self.errors if text not in self.reason
)
lines.extend(f"warning: {text}" for text in self.warnings)
return "\n".join(lines)
@dataclass(frozen=True)
class SensorCatalog:
"""Every configuration bundle the roots provide, with the roots themselves."""
roots: tuple[CatalogRoot, ...]
entries: tuple[CatalogEntry, ...]
user_root_decision: UserRootDecision
# ------------------------------------------------------------------ roots
@property
def shipped_root(self) -> Path:
return self.roots[0].path
@property
def user_root(self) -> Path:
return self.roots[1].path
@property
def user_root_exists(self) -> bool:
return self.roots[1].exists
@property
def user_root_kind(self) -> str:
return self.user_root_decision.kind
@property
def user_root_reason(self) -> str:
return self.user_root_decision.reason
# ------------------------------------------------------------------ entries
def entries_for_origin(self, origin: str) -> tuple[CatalogEntry, ...]:
wanted = str(origin or "")
return tuple(entry for entry in self.entries if entry.origin == wanted)
def entries_for_family(self, family: str) -> tuple[CatalogEntry, ...]:
"""The entries one family group lists; :data:`FAMILY_UNIDENTIFIED` is a group too."""
wanted = str(family or "")
if wanted == FAMILY_UNIDENTIFIED:
return tuple(entry for entry in self.entries if not entry.product_family)
return tuple(entry for entry in self.entries if entry.product_family == wanted)
def families(self) -> list[str]:
"""The family groups to offer, in the picker's order, unidentified last."""
present: list[str] = []
for entry in self.entries:
if entry.family not in present:
present.append(entry.family)
known = [name for name in KNOWN_FAMILY_ORDER if name in present]
others = sorted(
name
for name in present
if name not in KNOWN_FAMILY_ORDER and name != FAMILY_UNIDENTIFIED
)
tail = [FAMILY_UNIDENTIFIED] if FAMILY_UNIDENTIFIED in present else []
return known + others + tail
def invalid_entries(self) -> tuple[CatalogEntry, ...]:
return tuple(entry for entry in self.entries if not entry.valid)
def by_key(self, key: str) -> CatalogEntry | None:
wanted = str(key or "")
for entry in self.entries:
if entry.key == wanted:
return entry
return None
def by_ref(self, ref: str) -> CatalogEntry | None:
wanted = str(ref or "")
for entry in self.entries:
if entry.ref == wanted:
return entry
return None
def start_blocked(self, key_or_entry: CatalogEntry | str) -> str | None:
"""Why the named entry must not be started; ``None`` when it may be.
This is the whole answer a caller needs to refuse to start: it is ``None`` only for an
entry the SDK validated *and* that no other bundle duplicates.
"""
entry = (
key_or_entry
if isinstance(key_or_entry, CatalogEntry)
else self.by_key(str(key_or_entry))
)
return None if entry is None else entry.start_blocked_reason
def summary_lines(self) -> list[str]:
"""A short block for a log header or a diagnostics panel."""
lines = [
f"shipped root: {self.shipped_root}"
+ ("" if self.roots[0].exists else " (absent)"),
f"user root: {self.user_root} ({self.user_root_kind})"
+ ("" if self.user_root_exists else " (absent)"),
]
for entry in self.entries:
blocked = entry.start_blocked_reason
lines.append(
f" {entry.ref}: {entry.label()}" + (f" - {blocked}" if blocked else "")
)
return lines
def resolve_user_root() -> UserRootDecision:
"""The writable user bundle root, preferring the portable sibling.
Returns the decision rather than just a path, because which root is in use is a fact the
app has to be able to report: a user who cannot find their bundles needs to be told where
they went, not shown a path the app silently chose.
"""
sibling = user_sensors_dir()
if _is_writable(sibling):
return UserRootDecision(
path=sibling,
kind=ROOT_KIND_SIBLING,
writable=True,
reason=f"{sibling} (sibling of the shipped root; writable)",
)
fallback = fallback_user_sensors_dir()
return UserRootDecision(
path=fallback,
kind=ROOT_KIND_FALLBACK,
writable=_is_writable(fallback),
reason=f"{sibling} is not writable; using {fallback}",
)
def _is_writable(target: Path) -> bool:
"""Whether a file can be created in ``target``, or beside it when it does not exist.
A real write is the only honest test on Windows, where a read-only directory still answers
``os.access(..., os.W_OK)``. The directory itself is never created: reporting where user
bundles *would* go must not have the side effect of creating that place, and a catalogue
scan must not change the tree it is describing.
"""
directory = target if target.is_dir() else target.parent
if not directory.is_dir():
return False
probe = directory / f"{_WRITE_PROBE_PREFIX}-{os.getpid()}-{uuid.uuid4().hex[:8]}"
try:
probe.write_text("", encoding="utf-8")
except OSError:
return False
# A cleanup failure is not a writability finding: the file was created, which is the answer,
# and one empty probe file left behind is harmless.
with contextlib.suppress(OSError):
probe.unlink()
return True
def scan_sensor_catalog(
*,
shipped_root: str | Path | None = None,
user_root: str | Path | None = None,
) -> SensorCatalog:
"""Discover every bundle under the shipped and writable user roots.
``shipped_root`` defaults to :func:`orisys_app_shared.paths.sensors_dir` and ``user_root``
to whatever :func:`resolve_user_root` decided. Both are parameters so a test — or a caller
with an unusual layout — can point the catalog at another tree without touching the real
one. A root that does not exist contributes no entries and is not an error.
"""
shipped = Path(shipped_root) if shipped_root is not None else sensors_dir()
if user_root is None:
decision = resolve_user_root()
user = decision.path
user_origin = (
ORIGIN_FALLBACK if decision.kind == ROOT_KIND_FALLBACK else ORIGIN_USER
)
else:
user = Path(user_root)
decision = UserRootDecision(
path=user,
kind=ROOT_KIND_EXPLICIT,
writable=_is_writable(user),
reason=f"{user} (named by the caller)",
)
user_origin = ORIGIN_USER
roots = (
CatalogRoot(origin=ORIGIN_SHIPPED, path=shipped, exists=shipped.is_dir()),
CatalogRoot(origin=user_origin, path=user, exists=user.is_dir()),
)
entries: list[CatalogEntry] = []
for root in roots:
if root.exists:
entries.extend(_scan_root(root))
return SensorCatalog(
roots=roots,
entries=tuple(_mark_duplicates(entries)),
user_root_decision=decision,
)
def _scan_root(root: CatalogRoot) -> list[CatalogEntry]:
"""Every bundle folder directly under one root, in a stable order."""
try:
children = sorted(
(path for path in root.path.iterdir() if path.is_dir()), key=_sort_key
)
except OSError:
# An unreadable root lists nothing; the root itself is still reported, so the reason a
# family is missing remains visible above the (empty) entry list.
return []
found: list[CatalogEntry] = []
for folder in children:
entry = _entry_for(folder, root.origin)
if entry is not None:
found.append(entry)
return found
def _entry_for(folder: Path, origin: str) -> CatalogEntry | None:
"""One child folder, or ``None`` when it is not a bundle candidate at all.
A candidate holds at least one top-level JSON document, because both a descriptor and a
configuration are JSON. Whether it *is* a usable configuration is then the SDK's answer,
never this module's.
"""
json_files = _json_files(folder)
if not json_files:
return None
try:
profile = _sdk().resolve_bundle(folder)
except Exception as exc: # noqa: BLE001 - one broken bundle must not hide the others
return _unreadable_entry(folder, origin, exc)
return _resolved_entry(folder, origin, profile)
def _resolved_entry(
folder: Path, origin: str, profile: ResolvedSensorProfile
) -> CatalogEntry:
"""An entry the SDK resolved, valid or rejected, with its verdict kept whole."""
validation = profile.validation
errors = tuple(str(issue) for issue in validation.errors)
return CatalogEntry(
key=str(profile.sensor_json_path),
folder=folder.name,
bundle_path=folder.resolve(),
origin=origin,
display_name=profile.display_name,
product_family=profile.product_family,
profile_id=profile.profile_id,
sensor_json_path=profile.sensor_json_path,
processing_backend=profile.processing_backend,
valid=validation.usable,
state=validation.state,
reason="; ".join(errors),
errors=errors,
warnings=tuple(str(issue) for issue in validation.warnings),
diagnostics=tuple(profile.diagnostics),
profile=profile,
)
def _unreadable_entry(folder: Path, origin: str, exc: Exception) -> CatalogEntry:
"""An entry no bundle could be read from, with the SDK's explanation as its reason."""
display_name, family, profile_id = _best_effort_identity(folder)
sensor_json = folder / "sensor.json"
has_config = sensor_json.is_file()
return CatalogEntry(
key=str(sensor_json if has_config else folder),
folder=folder.name,
bundle_path=folder.resolve(),
origin=origin,
display_name=display_name,
product_family=family,
profile_id=profile_id,
sensor_json_path=sensor_json if has_config else None,
processing_backend="",
valid=False,
state=STATE_UNREADABLE,
reason=_unreadable_reason(folder, exc),
)
def _unreadable_reason(folder: Path, exc: Exception) -> str:
"""The SDK's own explanation, prefixed when the folder holds no configuration at all.
"Not a built-in bundle" is true of a folder of stray JSON but does not say *why* it is not
a configuration. Naming the files that are there does, and it is the sentence that stops
``force_calibration.json`` from being offered as a sensor.
"""
manifest = folder / _sdk().DESCRIPTOR_FILENAME
note = ""
if not manifest.is_file() and not (folder / "sensor.json").is_file():
names = ", ".join(path.name for path in _json_files(folder))
note = (
f"only {names} present; a sensor configuration needs sensor.json or a "
"manifest.json descriptor, so this folder is not offered as one. "
)
return f"{folder.name}: {note}the SDK rejected it: {exc}"
def _best_effort_identity(folder: Path) -> tuple[str, str, str]:
"""``(display_name, family, profile_id)`` a broken bundle still declares.
A bundle whose resolution failed should not reduce to a bare folder name when the fields
that *are* readable identify it. Only ``sensor.json`` and ``manifest.json`` are consulted:
a calibration document's keys are not a profile's identity. The entry stays invalid.
"""
manifest = folder / _sdk().DESCRIPTOR_FILENAME
sensor_json = folder / "sensor.json"
data: dict[str, Any] = {}
for path in (sensor_json, manifest):
parsed = _read_json_object(path)
if parsed:
data.update(parsed)
built_in = _sdk().BUILT_IN_PROFILES.get(folder.name, {})
display = _text(data.get("display_name")) or folder.name
family = _text(data.get("product_family")) or _text(built_in.get("product_family"))
return display, family, _text(data.get("profile_id"))
def _text(value: Any) -> str:
"""A non-empty stripped string, or ``""``."""
return value.strip() if isinstance(value, str) and value.strip() else ""
def _read_json_object(path: Path) -> dict[str, Any] | None:
"""A JSON object, or ``None`` when the file is unreadable or is not an object."""
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def _json_files(folder: Path) -> tuple[Path, ...]:
"""The top-level JSON documents in ``folder``, sorted by name."""
try:
found = [
path
for path in folder.iterdir()
if path.is_file() and path.suffix.lower() == ".json"
]
except OSError:
return ()
return tuple(sorted(found, key=_sort_key))
def _mark_duplicates(entries: list[CatalogEntry]) -> list[CatalogEntry]:
"""Flag every entry sharing a ``profile_id`` with another, naming the others.
Nothing is hidden or shadowed: both sides stay listed and each says which other bundles
declare the same profile, so a caller can refuse an ambiguous selection instead of
silently starting one of two profiles that claim to be the same thing.
"""
by_profile: dict[str, list[CatalogEntry]] = {}
for entry in entries:
if entry.profile_id:
by_profile.setdefault(entry.profile_id, []).append(entry)
if not any(len(group) > 1 for group in by_profile.values()):
return entries
marked: list[CatalogEntry] = []
for entry in entries:
others = tuple(
other.ref
for other in by_profile.get(entry.profile_id, [])
if other.key != entry.key
)
marked.append(replace(entry, duplicates=others) if others else entry)
return marked
+31 -28
View File
@@ -1,43 +1,46 @@
"""List local editable sensor bundles under config/sensors."""
"""List the sensor bundles the app offers, over both the shipped and user roots.
This used to be a hard-coded product list that made every folder under ``config/sensors`` a
product whether or not a bundle was there. The offered set now comes from
:mod:`orisys_app_shared.sensors.bundle_catalog`, which asks the SDK what each folder is: a
descriptor bundle, a legacy ``sensor.json`` bundle, or an entry that stays listed as invalid
with the SDK's own reason.
The two functions below stay the picker's compatibility surface. The picker itself uses the
catalog object directly, so it can show each entry's family, profile, origin and validity.
"""
from __future__ import annotations
from orisys_app_shared.paths import sensors_dir
#: Product types the UI offers.
#:
#: ``finger-mems`` is a deprecated product line that is deliberately **not** part of the SDK
#: (there is no MEMS read path there), so it is carried studio-side only: its bundle copies
#: ``box``'s ``sensor.json`` without a ``force_calibration.json``, which means N units are
#: unavailable and the app reports raw for it — the honest answer for an uncalibrated device.
#: It runs on the box/mini pipeline, because the worker routing is a binary ``== "finger"``
#: test (see ``session_controller.worker_for``).
#:
#: NOTE: the copied ``camera``/``stitcher`` numbers belong to the box prototype. If the real
#: MEMS-finger device stitches differently, live frames are refused with
#: ``field_domain_mismatch`` — correct them from the device, not by guessing.
PRODUCT_TYPES = ("box", "mini", "finger", "finger-mems")
from orisys_app_shared.sensors.bundle_catalog import (
FAMILY_UNIDENTIFIED,
scan_sensor_catalog,
)
def list_sensor_types() -> list[str]:
return list(PRODUCT_TYPES)
"""The family groups the installed bundles provide, unidentified last.
A family is offered when a bundle exists for it — including a bundle the SDK rejects, which
must stay visible with its reason rather than vanish from the picker.
"""
return scan_sensor_catalog().families()
def list_configs_for_type(sensor_type: str) -> list[tuple[str, str]]:
"""Return ``(display_name, config_key)`` entries for one sensor type.
"""Return ``(display_name, config_key)`` entries for one family.
Prefers ``config/sensors/<type>/sensor.json`` when present; otherwise a
single default entry named after the type (for prefs / future Run).
The key is the configuration file path the app already passes around (``sensor.json``, or
the bundle folder for an entry that has none), so a selection keeps its meaning. When no
bundle exists at all, a single default entry named after the type is offered — for
preferences and a future Run — exactly as before.
"""
name = str(sensor_type or "").strip()
if not name:
return []
root = sensors_dir() / name
sensor_json = root / "sensor.json"
if sensor_json.is_file():
return [(f"{name}/sensor.json", str(sensor_json))]
# Also accept extra *.json next to sensor.json later; for now only sensor.json.
extras = sorted(p for p in root.glob("*.json") if p.name != "manifest.json")
if extras:
return [(f"{name}/{p.name}", str(p)) for p in extras]
entries = scan_sensor_catalog().entries_for_family(name)
if entries:
return [(entry.label(), entry.key) for entry in entries]
if name == FAMILY_UNIDENTIFIED:
return []
return [(name, name)]
+292
View File
@@ -0,0 +1,292 @@
"""The sensor configuration catalog: discovery, validity and duplicate profile ids.
The catalog replaced two rules that were wrong in the same direction — a hard-coded product
list, and "any ``*.json`` beside a ``sensor.json`` is another configuration". These tests build
a tree containing exactly the shapes those rules mishandled: a descriptor bundle, a legacy
``sensor.json`` bundle, a folder holding only ``force_calibration.json``, a malformed
``manifest.json``, a bundle whose declared asset is missing, and two bundles sharing one
``profile_id``.
The tree is built here, in the test's own temporary directory: a committed fixture would be a
second copy of the bundle format that could drift from the real one without anyone noticing.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
from orisys_app_shared.sensors import (
FAMILY_UNIDENTIFIED,
ORIGIN_FALLBACK,
ORIGIN_SHIPPED,
ORIGIN_USER,
ROOT_KIND_FALLBACK,
ROOT_KIND_SIBLING,
STATE_UNREADABLE,
resolve_user_root,
scan_sensor_catalog,
)
CAMERA_OK = {"resolution_raw": [640, 480], "fps": 30, "resolution_stitch": [100, 100]}
TRACKER_OK = {"algorithm": "dis"}
def _config(
*, declared_type: str | None = None, stitcher: bool = False
) -> dict[str, Any]:
data: dict[str, Any] = {
"name": "fixture",
"camera": CAMERA_OK,
"tracker": TRACKER_OK,
}
if declared_type:
data["type"] = declared_type
if stitcher:
data["stitcher"] = {"n": [1, 1]}
return data
def _descriptor(profile_id: str, **overrides: Any) -> dict[str, Any]:
data: dict[str, Any] = {
"schema_version": 1,
"profile_id": profile_id,
"display_name": profile_id,
"product_family": "mini",
"processing_backend": "single_camera",
"sensor_json": "sensor.json",
}
data.update(overrides)
return data
def _write(path: Path, payload: object) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
text = payload if isinstance(payload, str) else json.dumps(payload, indent=2)
path.write_text(text, encoding="utf-8")
return path
@pytest.fixture
def trees(tmp_path):
"""One shipped root and one user root covering every shape the catalog must handle."""
shipped = tmp_path / "shipped"
user = tmp_path / "user"
# A legacy bundle: a built-in folder name, sensor.json, and no manifest. The config carries
# a stitcher block, which is what the SDK's own rule reads to reach compound_eye.
_write(shipped / "box" / "sensor.json", _config(stitcher=True))
# Arbitrary JSON only: not a configuration.
_write(shipped / "calib-only" / "force_calibration.json", {"axes": ["fn", "fx"]})
# A descriptor bundle.
_write(user / "mini-lab" / "sensor.json", _config())
_write(user / "mini-lab" / "manifest.json", _descriptor("orisys.mini.lab-r1"))
# A manifest that is not JSON at all.
_write(
user / "broken-manifest" / "manifest.json",
'{"schema_version": 1, "profile_id":',
)
# A descriptor whose declared computational asset is absent.
_write(user / "missing-assets" / "sensor.json", _config())
_write(
user / "missing-assets" / "manifest.json",
_descriptor(
"orisys.finger.missing-r1",
product_family="finger",
processing_backend="uv_unroll",
computational_assets={"unroll_map": "assets/not-here.npz"},
),
)
# Two bundles declaring one profile_id.
for name in ("dup-a", "dup-b"):
_write(user / name / "sensor.json", _config())
_write(user / name / "manifest.json", _descriptor("orisys.mini.duplicate-r1"))
# No JSON document at all: a folder, not a bundle.
_write(user / "notes" / "readme.txt", "not a bundle")
return scan_sensor_catalog(shipped_root=shipped, user_root=user)
def test_every_bundle_shape_is_discovered_with_its_origin(trees):
refs = {entry.ref for entry in trees.entries}
assert refs == {
"shipped/box",
"shipped/calib-only",
"user/mini-lab",
"user/broken-manifest",
"user/missing-assets",
"user/dup-a",
"user/dup-b",
}, f"a folder with no JSON document is not a bundle candidate: {sorted(refs)}"
assert trees.by_ref("shipped/box").origin == ORIGIN_SHIPPED
assert trees.by_ref("user/mini-lab").origin == ORIGIN_USER
def test_a_calibration_document_is_not_offered_as_a_configuration(trees):
entry = trees.by_ref("shipped/calib-only")
assert entry is not None, "the folder is retained, so its reason can be read"
assert not entry.valid
assert "force_calibration.json" in entry.reason
assert "not offered as one" in entry.reason
# Nothing about it may be presented as a product identity.
assert entry.product_family == ""
assert entry.family == FAMILY_UNIDENTIFIED
assert entry.profile_id == ""
assert entry.start_blocked_reason == entry.reason
def test_a_malformed_manifest_is_invalid_with_the_sdks_own_reason(trees):
entry = trees.by_ref("user/broken-manifest")
assert entry is not None and not entry.valid
assert entry.state == STATE_UNREADABLE
assert "unreadable descriptor" in entry.reason, entry.reason
def test_the_two_invalid_shapes_carry_distinct_reasons(trees):
calibration_only = trees.by_ref("shipped/calib-only").reason
malformed = trees.by_ref("user/broken-manifest").reason
assert calibration_only != malformed
assert "force_calibration.json" in calibration_only
assert "force_calibration.json" not in malformed
assert "unreadable descriptor" in malformed
assert "unreadable descriptor" not in calibration_only
def test_a_descriptor_and_a_legacy_bundle_both_resolve(trees):
descriptor = trees.by_ref("user/mini-lab")
assert descriptor.valid and descriptor.profile is not None
assert descriptor.profile_id == "orisys.mini.lab-r1"
assert descriptor.product_family == "mini"
assert descriptor.processing_backend == "single_camera"
assert descriptor.sensor_json_path == descriptor.bundle_path / "sensor.json"
assert descriptor.start_blocked_reason is None
legacy = trees.by_ref("shipped/box")
assert legacy.valid and legacy.profile is not None
# The identity is the SDK's built-in one for that folder ...
assert legacy.profile_id == "orisys.box.legacy"
assert legacy.product_family == "box"
# ... and the backend is the configuration's (the stitcher block), decided by the SDK.
assert legacy.processing_backend == "compound_eye"
assert legacy.sensor_json_path == legacy.bundle_path / "sensor.json"
def test_a_legacy_config_that_contradicts_the_builtin_table_is_listed_with_the_sdks_reason(
tmp_path,
):
"""A folder name identifies a legacy bundle; it must not decide its backend.
The configuration wins — the SDK says so and this asserts it — but the SDK then validates
the backend requirements against the *descriptor's* pre-override backend, so a legacy
bundle whose config contradicts the built-in table fails a check that does not apply to the
backend it actually uses. That is an SDK-side finding, reported rather than worked around
here: the catalog's job is to pass the verdict on, not to second-guess it. If this test ever
fails because the entry became valid, the SDK fixed it: delete the ``not entry.valid``
assertion and record where.
"""
shipped = tmp_path / "shipped"
_write(shipped / "box" / "sensor.json", _config(declared_type="single_camera"))
entry = scan_sensor_catalog(
shipped_root=shipped, user_root=tmp_path / "user"
).by_ref("shipped/box")
assert entry.profile is not None
assert entry.profile.processing_backend == "single_camera", (
"the config decides the backend"
)
assert entry.product_family == "box", "the folder name decides the legacy identity"
assert not entry.valid
assert "stitcher" in entry.reason
assert entry.start_blocked_reason == entry.reason
def test_a_shared_profile_id_is_flagged_and_both_entries_stay_listed(trees):
first = trees.by_ref("user/dup-a")
second = trees.by_ref("user/dup-b")
assert first.valid and second.valid, "sharing a profile_id is not itself a failure"
assert first.profile_id == second.profile_id == "orisys.mini.duplicate-r1"
assert first.duplicates == ("user/dup-b",)
assert second.duplicates == ("user/dup-a",)
assert first.duplicate and second.duplicate
assert "user/dup-b" in first.start_blocked_reason
assert "user/dup-a" in second.start_blocked_reason
# Nothing is shadowed: both are still offered under their family, and by key.
offered = {entry.key for entry in trees.entries_for_family("mini")}
assert {first.key, second.key} <= offered
assert trees.start_blocked(first.key) == first.start_blocked_reason
assert trees.start_blocked(second.key) == second.start_blocked_reason
def test_a_missing_computational_asset_is_reported_and_keeps_its_identity(trees):
entry = trees.by_ref("user/missing-assets")
assert not entry.valid
assert entry.state == "failed", "the SDK's own state, not the catalog's"
assert entry.profile_id == "orisys.finger.missing-r1"
assert entry.product_family == "finger"
assert "unroll_map" in entry.reason
assert entry.start_blocked_reason
def test_an_entry_the_sdk_rejects_is_never_reported_as_startable(trees):
for entry in trees.entries:
expected = entry.valid and not entry.duplicate
assert (entry.start_blocked_reason is None) == expected, entry.ref
def test_the_label_states_family_profile_origin_and_validity(trees):
entry = trees.by_ref("user/missing-assets")
label = entry.label()
assert "finger" in label
assert entry.profile_id in label
assert ORIGIN_USER in label
assert entry.state in label
def test_the_root_decision_names_the_root_in_use():
decision = resolve_user_root()
assert decision.kind in (ROOT_KIND_SIBLING, ROOT_KIND_FALLBACK)
assert str(decision.path) in decision.reason
def test_an_unwritable_sibling_falls_back_to_the_per_user_root(tmp_path, monkeypatch):
from orisys_app_shared.sensors import bundle_catalog
sibling = tmp_path / "no" / "such" / "sensors-user"
fallback = tmp_path / "per-user"
monkeypatch.setattr(bundle_catalog, "user_sensors_dir", lambda: sibling)
monkeypatch.setattr(bundle_catalog, "fallback_user_sensors_dir", lambda: fallback)
decision = bundle_catalog.resolve_user_root()
assert decision.kind == ROOT_KIND_FALLBACK
assert decision.path == fallback
assert decision.writable is True
assert str(sibling) in decision.reason
def test_entries_under_the_fallback_root_keep_that_origin(tmp_path, monkeypatch):
from orisys_app_shared.sensors import bundle_catalog
shipped = tmp_path / "shipped"
fallback = tmp_path / "per-user"
_write(fallback / "mini-lab" / "sensor.json", _config())
_write(fallback / "mini-lab" / "manifest.json", _descriptor("orisys.mini.lab-r1"))
monkeypatch.setattr(
bundle_catalog, "user_sensors_dir", lambda: tmp_path / "no" / "such"
)
monkeypatch.setattr(bundle_catalog, "fallback_user_sensors_dir", lambda: fallback)
catalog = bundle_catalog.scan_sensor_catalog(shipped_root=shipped)
assert [entry.ref for entry in catalog.entries_for_origin(ORIGIN_FALLBACK)] == [
"fallback/mini-lab"
]
assert catalog.user_root_kind == ROOT_KIND_FALLBACK
def test_an_absent_root_is_not_an_error(tmp_path):
catalog = scan_sensor_catalog(
shipped_root=tmp_path / "gone", user_root=tmp_path / "also-gone"
)
assert catalog.entries == ()
assert catalog.roots[0].exists is False
assert catalog.user_root_exists is False
assert catalog.families() == []