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
+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() == []