Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -85,19 +85,31 @@ LOG_LEVEL=INFO
# LONGEST prefix, and each prefix must be distinct (a duplicate is refused
# at boot rather than silently discarding the earlier route's flags).
#
# Per-route flags, both declared facts and never inferred from substrate:
# is_simulated this route drives a simulator, even over real Channel
# Access (a soft IOC speaks real CA). Feeds the Dataset
# provenance gate that blocks promoting simulator data.
# read_only CORA may read and subscribe here but never write.
# Per-route expressiveness INSIDE a writable deployment
# ("drive the stage, never the shutter"). It defaults to
# false, so it is NOT how you make a deployment
# observe-only: use CONTROL_WRITES_ENABLED=false for that.
# Per-route declarations, all declared facts and never inferred from substrate:
# is_simulated this route drives a simulator, even over real Channel
# Access (a soft IOC speaks real CA). Feeds the Dataset
# provenance gate that blocks promoting simulator data.
# read_only CORA may read and subscribe here but never write.
# Per-route expressiveness INSIDE a writable deployment
# ("drive the stage, never the shutter"). It defaults to
# false, so it is NOT how you make a deployment
# observe-only: use CONTROL_WRITES_ENABLED=false for that.
# text_addresses epics_ca only. Addresses whose EPICS DBR_CHAR waveform
# carries a NUL-terminated string (e.g. tomoscan's
# ScanStatus, FileName) rather than raw bytes (e.g. an
# NTNDArray image). EPICS gives both the same wire type,
# so undeclared addresses read as Measurement(kind=
# "Array", value=<tuple of ints>); declared ones decode
# to Measurement(kind="Scalar", value=<str>). A no-op on
# other substrates and on addresses that never resolve
# to DBR_CHAR.
#
# CONTROL_PORT_ROUTES='[
# {"prefix":"2bma:cam1:image","substrate":"epics_pva"},
# {"prefix":"2bma:shutter:","substrate":"epics_ca","read_only":true},
# {"prefix":"2bmb:TomoScan:","substrate":"epics_ca","read_only":true,
# "text_addresses":["2bmb:TomoScan:ScanStatus","2bmb:TomoScan:FileName",
# "2bmb:TomoScan:FilePath","2bmb:TomoScan:FullFileName"]},
# {"prefix":"2bma:","substrate":"epics_ca"}
# ]'

Expand Down
22 changes: 22 additions & 0 deletions apps/api/src/cora/infrastructure/control_port_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ class the factory will construct for this route. `is_simulated`
`Settings.control_writes_enabled`, which cannot be partially
applied. Reach for the switch to make a deployment observe-only;
reach for this field only to carve a hole in a writable one.

`text_addresses` declares which addresses on this route carry text
in an EPICS DBR_CHAR waveform. EPICS gives a char waveform holding
a NUL-terminated string (tomoscan's `ScanStatus`, `FileName`, ...)
and one holding raw bytes (an NTNDArray image) the same wire type,
so the adapter cannot tell them apart and must be told. Meaningful
only for `epics_ca` (`EpicsCaControlPort` is the sole reader of it;
PVA's NTScalar and Tango's DevString already carry this distinction
on the wire); a route on another substrate that sets it is inert
rather than rejected, since the address list still describes a
true fact about the deployment, just one this substrate's adapter
has no ambiguity to resolve.
"""

prefix: str = Field(..., min_length=1)
Expand All @@ -90,6 +102,16 @@ class the factory will construct for this route. `is_simulated`
"deployment observe-only, set CONTROL_WRITES_ENABLED=false instead."
),
)
text_addresses: tuple[str, ...] = Field(
default=(),
description=(
"Addresses on this route whose EPICS DBR_CHAR waveform carries a "
"NUL-terminated string rather than raw bytes. Declared per deployment, "
"never inferred: EPICS gives a string-bearing char waveform and a "
"byte-bearing one (e.g. NTNDArray image data) the same wire type. "
"Applies to epics_ca routes only; a no-op on other substrates."
),
)

model_config = {"extra": "forbid"}

Expand Down
25 changes: 15 additions & 10 deletions apps/api/src/cora/operation/adapters/control_port_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
from collections.abc import Sequence
from typing import Any, Literal

from cora.infrastructure.control_port_route import ControlPortRoute, Substrate
from cora.infrastructure.control_port_route import ControlPortRoute
from cora.operation.adapters.control_port_registry import ControlPortRegistry
from cora.operation.adapters.epics_ca_control_port import EpicsCaControlPort
from cora.operation.adapters.epics_pva_control_port import EpicsPvaControlPort
Expand Down Expand Up @@ -130,7 +130,7 @@ def build_control_port(routes: Sequence[ControlPortRoute], *, writes_enabled: bo
registry.register_substrate_port(
route.prefix,
_guarded_substrate(
_build_substrate(route.substrate),
_build_substrate(route),
read_only=read_only,
scope=scope,
prefix=prefix,
Expand Down Expand Up @@ -190,21 +190,26 @@ def _guarded_substrate(
return ReadOnlySubstratePort(port, scope=scope, prefix=prefix)


def _build_substrate(substrate: Substrate) -> SubstrateControlPort[Any]:
def _build_substrate(route: ControlPortRoute) -> SubstrateControlPort[Any]:
"""Construct the per-substrate typed adapter with deployment defaults.

Handles the typed-address substrate adapters only; `in_memory` is
registered through `ControlPortRegistry.register_control_port` in
`build_control_port` because it is `str`-surfaced.

Per-adapter constructor kwargs (timeouts, etc.) ride on the
adapter defaults today; a future iteration may widen
`ControlPortRoute` with optional per-route overrides
(`timeout_s`, etc.) when a real deployment surfaces the need.
`route.text_addresses` is CA-specific (see `ControlPortRoute`
docstring) and reaches only `EpicsCaControlPort`; PVA and Tango
routes carry the field but nothing here reads it for them, which
is the field's declared inert-elsewhere contract, not an omission.

Remaining per-adapter constructor kwargs (timeouts, etc.) ride on
the adapter defaults today; a future iteration may widen
`ControlPortRoute` with further per-route overrides when a real
deployment surfaces the need.
"""
if substrate == "epics_ca":
return EpicsCaControlPort()
if substrate == "epics_pva":
if route.substrate == "epics_ca":
return EpicsCaControlPort(text_addresses=route.text_addresses)
if route.substrate == "epics_pva":
return EpicsPvaControlPort()
return TangoControlPort()

Expand Down
84 changes: 76 additions & 8 deletions apps/api/src/cora/operation/adapters/epics_ca_control_port.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,28 @@
`.enums` for label resolution, then caches the labels per-address so
subsequent reads stay on the cheap FORMAT_TIME path.

## DBR_CHAR waveforms: bytes, or a string wearing bytes' clothes

A DBR_CHAR waveform (`aioca.DBR_CHAR`, `element_count > 1`) reaches
`_kind_for` indistinguishably from any other array type and is
unpacked as a tuple of small integers: correct for a byte-array
payload (an NTNDArray image plugin's raw data), wrong for what many
IOCs use the same record shape for, a NUL-terminated ASCII string
too long for a `stringout`'s 40-character limit (tomoscan's
`ScanStatus`, `FileName`, `FilePath`, `FullFileName`). EPICS assigns
both the same wire type, so nothing in the reading itself says which
this is; `EpicsCaControlPort` is told via `text_addresses`, a
deployment-declared set of PVs to decode as text rather than an
integer tuple. Declaring an address that never resolves to DBR_CHAR
is inert, not an error: the declaration describes what the PV
carries, and a route that also lists the wrong PVs merely finds
nothing to apply it to.

A decoded string is cut at the first NUL: a fixed-size waveform
written with a shorter string than its NELM leaves trailing NULs
(or whatever was in the buffer previously) past the terminator, and
everything from the first NUL on is padding, not payload.

## Error mapping

aioca raises ONE exception class, `CANothing(name, errorcode)`,
Expand Down Expand Up @@ -110,6 +132,7 @@
from typing import TYPE_CHECKING, Any

from aioca import (
DBR_CHAR,
DBR_ENUM,
FORMAT_CTRL,
FORMAT_TIME,
Expand All @@ -134,7 +157,7 @@
)

if TYPE_CHECKING:
from collections.abc import AsyncGenerator
from collections.abc import AsyncGenerator, Iterable

from cora.operation.ports.control_address import EpicsPvAddress

Expand Down Expand Up @@ -280,10 +303,48 @@ def _produced_at_for(timestamp: float) -> datetime | None:
return datetime.fromtimestamp(timestamp, tz=UTC)


def _to_reading(augmented: Any, enum_labels: tuple[str, ...] | None) -> Measurement:
"""Translate an aioca `AugmentedValue` (FORMAT_TIME) to `Measurement`."""
kind = _kind_for(augmented.datatype, augmented.element_count)
value = _unpack_value(augmented, kind, enum_labels)
def _decode_char_waveform(augmented: Any) -> str:
"""Decode a DBR_CHAR waveform holding a NUL-terminated string.

Only called once the caller has confirmed both that the reading is
actually DBR_CHAR and that the deployment declared this address as
text (see module docstring, "DBR_CHAR waveforms"). `.tolist()`
covers the numpy-array case aioca returns for a populated waveform;
the plain `list()` fallback covers a zero-length reading, which
aioca can hand back as an empty non-numpy sequence.
"""
raw = augmented.tolist() if hasattr(augmented, "tolist") else list(augmented)
return bytes(raw).split(b"\x00", 1)[0].decode("utf-8", errors="replace")


def _to_reading(
augmented: Any,
enum_labels: tuple[str, ...] | None,
*,
as_text: bool = False,
) -> Measurement:
"""Translate an aioca `AugmentedValue` (FORMAT_TIME) to `Measurement`.

`as_text` is the caller's `text_addresses` declaration for this
specific address, not a property of the reading. It only takes
effect when the reading is actually a DBR_CHAR *waveform*
(`element_count > 1`, matching `_kind_for`'s own Array threshold
and the module docstring's "DBR_CHAR waveforms" scope): a
declaration cannot manufacture a wire type the substrate did not
send, so a stale or misdirected declaration is inert rather than
corrupting. The `element_count > 1` half of that guard matters on
its own: aioca collapses a length-1 DBR_CHAR waveform to its
scalar `ca_int` type, which has neither `.tolist()` nor
`__iter__`, so decoding it as a waveform would raise `TypeError`
instead of falling through inert.
"""
is_text = as_text and int(augmented.datatype) == DBR_CHAR and augmented.element_count > 1
kind: MeasurementKind = (
"Scalar" if is_text else _kind_for(augmented.datatype, augmented.element_count)
)
value = (
_decode_char_waveform(augmented) if is_text else _unpack_value(augmented, kind, enum_labels)
)
severity = int(getattr(augmented, "severity", 0))
status = int(getattr(augmented, "status", 0))
timestamp = float(getattr(augmented, "timestamp", 0.0))
Expand Down Expand Up @@ -315,8 +376,14 @@ class EpicsCaControlPort:
See module docstring for the connection model + ACL table.
"""

def __init__(self, *, default_timeout_s: float = _DEFAULT_TIMEOUT_S) -> None:
def __init__(
self,
*,
default_timeout_s: float = _DEFAULT_TIMEOUT_S,
text_addresses: Iterable[str] = (),
) -> None:
self._default_timeout_s = default_timeout_s
self._text_addresses = frozenset(text_addresses)
self._enum_labels: dict[str, tuple[str, ...]] = {}
self._closed = False

Expand Down Expand Up @@ -356,7 +423,7 @@ async def read(self, address: EpicsPvAddress) -> Measurement:
labels: tuple[str, ...] | None = None
if _kind_for(augmented.datatype, augmented.element_count) == "Categorical":
labels = await self._resolve_enum_labels(pv)
return _to_reading(augmented, labels)
return _to_reading(augmented, labels, as_text=pv in self._text_addresses)

async def write(
self,
Expand Down Expand Up @@ -426,6 +493,7 @@ async def _drain(self, address: str) -> AsyncGenerator[Measurement]:
format=FORMAT_TIME,
notify_disconnect=True,
)
as_text = address in self._text_addresses
try:
labels: tuple[str, ...] | None = None
while True:
Expand All @@ -437,7 +505,7 @@ async def _drain(self, address: str) -> AsyncGenerator[Measurement]:
and _kind_for(update.datatype, update.element_count) == "Categorical"
):
labels = await self._resolve_enum_labels(address)
yield _to_reading(update, labels)
yield _to_reading(update, labels, as_text=as_text)
finally:
with contextlib.suppress(Exception):
sub.close()
Expand Down
26 changes: 26 additions & 0 deletions apps/api/tests/integration/_softioc.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@
- `long_value` (DBR_LONG, `longout`) -> `Measurement(kind="Scalar")`
- `string_value` (DBR_STRING, `stringout`) -> `Measurement(kind="Scalar")`
- `waveform` (DBR_DOUBLE x 4, `waveform`) -> `Measurement(kind="Array")`
- `text_waveform` (DBR_CHAR x 256, `waveform`) -> `Measurement(kind="Array")`
by default; `Measurement(kind="Scalar", value=<str>)` when the caller
declares it via `EpicsCaControlPort(text_addresses={...})`, exercising
the tomoscan `ScanStatus`-shaped ambiguity (see that adapter's
"DBR_CHAR waveforms" module-docstring section)
- `text_waveform_nelm1` (DBR_CHAR x 1, `waveform`) -> aioca collapses a
length-1 char waveform to its scalar `ca_int` type; declaring it via
`text_addresses` must stay inert (no `.tolist()` / not iterable, so
the naive "any DBR_CHAR is a waveform" version of the decode crashed
here before the `element_count > 1` guard was added)
- `enum_value` (DBR_ENUM, `mbbo` with 3 strings) -> `Measurement(kind="Categorical")`
- `major_alarm_value` (`ao` with HIHI threshold tripped, HHSV=MAJOR)
-> `Measurement(quality="Uncertain")`
Expand Down Expand Up @@ -136,6 +146,22 @@
field(PINI, "YES")
}

record(waveform, "$(P)text_waveform") {
field(DESC, "DBR_CHAR waveform, NUL-terminated string")
field(DTYP, "Soft Channel")
field(NELM, "256")
field(FTVL, "UCHAR")
field(PINI, "YES")
}

record(waveform, "$(P)text_waveform_nelm1") {
field(DESC, "DBR_CHAR waveform, NELM=1")
field(DTYP, "Soft Channel")
field(NELM, "1")
field(FTVL, "UCHAR")
field(PINI, "YES")
}

record(mbbo, "$(P)enum_value") {
field(DESC, "DBR_ENUM with closed label set")
field(DTYP, "Soft Channel")
Expand Down
Loading
Loading