把 Studio 主机协议改成单向端口,并让「从视频重算」走分析入口

控制器经 AcquireGate / ImageSink / ShellNotes 与信号接线,采集 tick 用 acquire_presenter 上屏;三维测试证明分析结果已喂 3D 后,重算并入 on_analysis_requested。
验证:apps/studio 下相关 pytest 与 tools/ui_check.py(634 checks)通过。
This commit is contained in:
ykcheungab
2026-10-05 14:00:24 +08:00
parent 25ccffd814
commit 28e955a817
27 changed files with 1764 additions and 745 deletions
@@ -1,8 +1,8 @@
"""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.
bundles exist, what each one is (name, origin, calibration selection and the SDK's verdict), and
where a user's own bundles go on this machine.
"""
from orisys_app_shared.sensors.bundle_catalog import (
@@ -14,6 +14,9 @@ from orisys_app_shared.sensors.bundle_catalog import (
ROOT_KIND_EXPLICIT,
ROOT_KIND_FALLBACK,
ROOT_KIND_SIBLING,
SHAPE_COMPATIBILITY,
SHAPE_DESCRIPTOR,
SHAPE_UNREADABLE,
STATE_UNREADABLE,
CatalogEntry,
CatalogRoot,
@@ -33,6 +36,9 @@ __all__ = [
"ROOT_KIND_EXPLICIT",
"ROOT_KIND_FALLBACK",
"ROOT_KIND_SIBLING",
"SHAPE_COMPATIBILITY",
"SHAPE_DESCRIPTOR",
"SHAPE_UNREADABLE",
"STATE_UNREADABLE",
"CatalogEntry",
"CatalogRoot",
@@ -24,6 +24,13 @@ 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.
What the picker *says* about an entry is deliberately not what it is called internally. The
label names the product, whether the bundle shipped with the app or came from the operator, and
what the calibration selection gives — a named file, or raw force. The ``profile_id``, the
bundle's shape and the SDK's own ``ok``/``degraded``/``failed`` verdict live in
:meth:`CatalogEntry.tooltip`: they inform the operator who asked, instead of heading the row
with a word that only says "something optional is missing".
"""
from __future__ import annotations
@@ -43,7 +50,7 @@ from orisys_app_shared.paths import (
)
if TYPE_CHECKING: # pragma: no cover - typing only
from orisys.sensors import ResolvedSensorProfile
from orisys.sensors import Issue, ResolvedSensorProfile
#: Which root an entry came from.
ORIGIN_SHIPPED = "shipped"
@@ -70,6 +77,45 @@ KNOWN_FAMILY_ORDER: tuple[str, ...] = ("box", "mini", "finger", "finger-mems")
#: the SDK's own :attr:`ValidationReport.state` (``ok`` / ``degraded`` / ``failed``).
STATE_UNREADABLE = "invalid"
#: How each origin reads in the picker label. ``fallback`` is still the operator's own bundles:
#: which directory the writable root resolved to is a tooltip fact, not a picker distinction.
ORIGIN_LABELS: dict[str, str] = {
ORIGIN_SHIPPED: "Shipped",
ORIGIN_USER: "User",
ORIGIN_FALLBACK: "User",
}
#: How the SDK read a bundle. Stable identifiers, so a surface that has to translate the fact
#: (or branch on it) never matches on a sentence.
SHAPE_DESCRIPTOR = "descriptor"
SHAPE_COMPATIBILITY = "compatibility"
SHAPE_UNREADABLE = "unreadable"
_SHAPE_NOTES: dict[str, str] = {
SHAPE_DESCRIPTOR: "descriptor bundle",
SHAPE_COMPATIBILITY: "compatibility bundle, no descriptor",
SHAPE_UNREADABLE: "no readable bundle",
}
#: What the SDK's verdict *means*, said rather than named. The SDK's own state word is kept
#: beside it because the tooltip is where the technical facts have to survive; on its own,
#: "degraded" reads as a defect report about a healthy bundle.
_STATE_NOTES: dict[str, str] = {
"ok": "processing can start",
"degraded": "processing can start; something optional is missing",
"failed": "processing cannot start",
STATE_UNREADABLE: "no bundle could be read here",
}
#: The SDK's own issue codes whose operator-facing meaning is a capability, not the finding.
#: A code that is not listed falls back to the SDK's message, so a renamed code cannot silently
#: drop a warning.
_CAPABILITY_NOTES: dict[str, str] = {
"calibration.absent": (
"no calibration selected — calibrated force is unavailable; raw force is available"
),
}
_WRITE_PROBE_PREFIX = ".orisys-write-probe"
@@ -89,6 +135,18 @@ def _sort_key(path: Path) -> str:
return path.name.lower()
def _sentence(issue: Issue) -> str:
"""One SDK finding as a sentence an operator can read.
The code goes: ``calibration.applicability-unknown`` is a name, and the message beside it is
already the sentence. A warning whose meaning is a capability is replaced by that capability
instead of the finding, so the operator reads what the bundle will and will not give. The
file it is about stays — that is a fact, not a code.
"""
text = _CAPABILITY_NOTES.get(issue.code, issue.message)
return f"{text} [{issue.path}]" if issue.path is not None else text
@dataclass(frozen=True)
class UserRootDecision:
"""Where the writable user bundles live, and why."""
@@ -139,8 +197,11 @@ class CatalogEntry:
valid: bool
state: str
reason: str = ""
errors: tuple[str, ...] = ()
warnings: tuple[str, ...] = ()
#: The SDK's own findings, kept as issues rather than as rendered strings: the tooltip owes
#: the operator a sentence per warning, and a code whose meaning is a capability has to be
#: recognisable to be replaced by it.
errors: tuple[Issue, ...] = ()
warnings: tuple[Issue, ...] = ()
diagnostics: tuple[str, ...] = ()
#: ``origin/folder`` of every other bundle declaring this entry's ``profile_id``.
duplicates: tuple[str, ...] = ()
@@ -156,6 +217,60 @@ class CatalogEntry:
"""The family group this entry is listed under; never empty."""
return self.product_family or FAMILY_UNIDENTIFIED
@property
def name(self) -> str:
"""What the operator calls this configuration: its name, family, or folder."""
return self.display_name or self.product_family or self.folder
@property
def origin_label(self) -> str:
"""``Shipped`` or ``User``: where this configuration came from."""
return ORIGIN_LABELS.get(self.origin, self.origin)
@property
def shape(self) -> str:
"""How the SDK read this bundle: :data:`SHAPE_DESCRIPTOR` and friends.
``backend_source`` is the SDK's own report of where the processing backend came from,
and only a parsed ``manifest.json`` descriptor reports ``manifest`` — so this is the
SDK's answer, not the catalog reading the folder a second time.
"""
profile = self.profile
if profile is None:
return SHAPE_UNREADABLE
if profile.backend_source == "manifest":
return SHAPE_DESCRIPTOR
return SHAPE_COMPATIBILITY
@property
def shape_note(self) -> str:
"""The bundle's shape as the sentence the tooltip shows."""
return _SHAPE_NOTES.get(self.shape, _SHAPE_NOTES[SHAPE_UNREADABLE])
@property
def selected_calibration(self) -> str:
"""The calibration the SDK resolved for this bundle, by file name; ``""`` for none.
Read from the resolution rather than from the directory: a calibration file merely
existing proves nothing, and the SDK never substitutes a different one.
"""
profile = self.profile
path = None if profile is None else profile.calibration.path
return path.name if path is not None else ""
@property
def calibration_note(self) -> str:
"""What the calibration *selection* gives: a named file, or raw force.
A calibration is a separate optional artifact chosen for a session, never a property of
the bundle, so an absent one is stated as what is not selected — not as a bundle that is
uncalibrated, and never as a defect.
"""
selected = self.selected_calibration
if selected:
return f"Calibration: {selected}"
return "No calibration selected — raw force"
@property
def duplicate(self) -> bool:
return bool(self.duplicates)
@@ -178,18 +293,39 @@ class CatalogEntry:
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}"
"""One line for a picker item: what it is, where it came from, what it will give.
Deliberately absent: the ``profile_id`` (identity no operator can act on), the SDK's
``ok``/``degraded``/``failed`` verdict (a severity with no information in it) and the
word ``legacy`` (which means "read through the compatibility adapter", not "old").
What is left is the choice itself: the product, its origin, and the force a session
started from it will produce.
"""
name = self.name
if not self.valid:
return f"{name} · Cannot start: {self.start_blocked_reason}"
origin = self.origin_label
if self.duplicates:
return f"{name} · {origin} · Also provided by {', '.join(self.duplicates)}"
return f"{name} · {origin} · {self.calibration_note}"
def tooltip(self) -> str:
"""The facts behind the label, including why the entry is not startable."""
"""The facts behind the label, including why the entry is not startable.
The technical half the picker row has no room for: the identity the SDK assigned, how
the bundle was read, its validation, and every finding as a sentence rather than as a
code — ``calibration.absent`` says nothing an operator can use, "calibrated force is
unavailable; raw force is available" says everything.
"""
state_note = _STATE_NOTES.get(self.state, "verdict not recognised")
lines = [
f"{self.display_name} ({self.ref})",
f"{self.name} ({self.ref}) — {self.shape_note}",
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}",
f"SDK validation: {self.state} ({state_note})",
f"calibration: {self.selected_calibration or 'none selected — raw force'}",
]
blocked = self.start_blocked_reason
if blocked:
@@ -197,9 +333,11 @@ class CatalogEntry:
# 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
f"error: {_sentence(issue)}"
for issue in self.errors
if str(issue) not in self.reason
)
lines.extend(f"warning: {text}" for text in self.warnings)
lines.extend(f"warning: {_sentence(issue)}" for issue in self.warnings)
return "\n".join(lines)
@@ -436,7 +574,10 @@ def _resolved_entry(
) -> 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)
# The issues are kept as the SDK objects, not stringified here: the tooltip owes a sentence
# per warning, and the one place the code belongs is the SDK's own reason, which is what
# "Cannot start" quotes.
errors = tuple(validation.errors)
return CatalogEntry(
key=str(profile.sensor_json_path),
folder=folder.name,
@@ -449,9 +590,9 @@ def _resolved_entry(
processing_backend=profile.processing_backend,
valid=validation.usable,
state=validation.state,
reason="; ".join(errors),
reason="; ".join(str(issue) for issue in errors),
errors=errors,
warnings=tuple(str(issue) for issue in validation.warnings),
warnings=tuple(validation.warnings),
diagnostics=tuple(profile.diagnostics),
profile=profile,
)
@@ -7,7 +7,8 @@ descriptor bundle, a legacy ``sensor.json`` bundle, or an entry that stays liste
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.
catalog object directly, so it can show each entry's name, origin and calibration selection,
with the identity and the SDK's verdict in the tooltip.
"""
from __future__ import annotations