18. Typed Command to replace SignalX#
Date: 2026-04-10
Status#
Accepted
Context#
SignalX was the existing mechanism for triggering hardware actions with no input and no return
value. It was implemented as a thin wrapper around SignalBackend — the wrong base class:
a command is not a readable signal and has no get/subscribe semantics. More importantly,
SignalX could never represent typed commands (those taking arguments or returning values),
which are needed for Tango device commands and future EPICS PVA RPC support.
Decision#
Command extends Device, not a separate hierarchy#
Device already provides named tree nodes, parent/child traversal, connection lifecycle, logging,
and the entire mock infrastructure. As a Device subclass, commands are discovered by
DeviceFiller annotation scanning automatically, appear in device trees, and participate in mock
mode without extra machinery.
TriggerableCommand is a separate subclass, not a conditional method on Command#
Command[P, T].execute() returns AsyncStatus[T] — a typed remote procedure call.
TriggerableCommand.trigger() returns AsyncStatus[None] and satisfies the bluesky
Triggerable protocol used by scan machinery.
These are distinct protocols consumed by distinct callers: a StandardDetector trigger loop calls
.trigger() and assumes Triggerable; a user invoking a typed command calls .execute() and
expects a typed return. They cannot be merged onto one class: putting trigger() on Command
would force every Command[int, float] author to decide what trigger() means, and making it
conditional on P == [] cannot be expressed in Python’s type system. TriggerableCommand is a
concrete subclass that resolves this cleanly: only void/void commands satisfy Triggerable.
SignalX is deprecated, not removed#
SignalX is widely used in existing deployed device code. Removing it would silently break user
devices on upgrade. A DeprecationWarning guides migration to TriggerableCommand while
preserving backward compatibility.
EPICS is intentionally restricted to void/void commands#
CA has no native typed RPC mechanism; the conventional trigger is a plain caput to an integer
PROC field. PVA has an RPC mechanism in principle but it is not yet standardized enough for
production device code. Annotating a typed Command[int, float] on an EPICS device raises
TypeError at device-construction time; the alternative — silently ignoring the type parameters
— would let the command appear to work but drop its arguments at runtime.
CaCommandBackend.connect() additionally rejects float and double DBR types intentionally:
PROC fields on process records are always integer-typed, so a float PV indicates the wrong record
being used as a trigger rather than a signal.
Concurrent SoftCommandBackend calls are serialised, not rejected#
An asyncio.Lock ensures a second concurrent execute() call waits for the first to complete
rather than running alongside it. This matches the exclusive-access semantics expected for
hardware commands. Raising on concurrent access would transfer the locking responsibility to
every caller.
Mock mode calls the original SoftCommandBackend function by default#
When a soft_command device is connected in mock mode, MockCommandBackend calls the original
Python callable by default, so tests see the same side effects and return values as production
without any extra setup. This is parallel to MockSignalBackend, which has the same behavior in real and mock mode of storing the supplied value.
Use callback_on_mock_execute to
suppress the original function for tests that need to isolate the caller from the callee.
Consequences#
SignalXis retained but deprecated; existing code continues to work.Typed
Command[P, T]on an EPICS device raisesTypeErrorat construction.EPICS CA command PVs must be integer scalar types; float PVs raise
TypeErrorat connect.PVA RPC with typed arguments is deferred to a future PR via a
call_specfield onPvaCommandBackend.