# 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 - `SignalX` is retained but deprecated; existing code continues to work. - Typed `Command[P, T]` on an EPICS device raises `TypeError` at construction. - EPICS CA command PVs must be integer scalar types; float PVs raise `TypeError` at connect. - PVA RPC with typed arguments is deferred to a future PR via a `call_spec` field on `PvaCommandBackend`.