Skip to main content

epics_base_rs/server/
device_support.rs

1use crate::error::CaResult;
2use crate::server::record::{AlarmSeverity, ProcessAction, Record, RecordInstance, ScanType};
3
4/// Which of C's three built-in soft-channel dset families a DTYP names.
5///
6/// The question every caller actually asks is *which flavour*, not "is it
7/// soft": each of the three does something different with the link, and a
8/// caller that answers only yes/no has to re-spell the distinction itself.
9/// Three sites did, each with its own two-value expression, and
10/// [`SoftDtyp::Async`] fell out of all three — the attach phase skipped its
11/// device (soft), while the processing cycle and the output path deferred to a
12/// device that therefore did not exist. Input records read 0 instead of the
13/// link value and output records wrote nothing, with no alarm.
14///
15/// Matching on this enum is what keeps that closed: a fourth flavour is a
16/// non-exhaustive `match`, which is a compile error rather than a silent zero.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum SoftDtyp {
19    /// `""` or `"Soft Channel"` — C's `devXxxSoft.c`. Puts VAL/OVAL on the
20    /// link; the input `read_xxx` returns 2, "do not convert".
21    Plain,
22    /// `"Raw Soft Channel"` — C's `devXxxSoftRaw.c`, a DIFFERENT dset that
23    /// puts RVAL (`devAoSoftRaw.c:44`) and whose input `read_xxx` returns 0
24    /// so the record DOES run the RVAL→VAL convert.
25    Raw,
26    /// `"Async Soft Channel"` — C's `devXxxSoftCallback.c`. Same VALUES as
27    /// [`SoftDtyp::Plain`], deferred: `write_ao` is `dbPutLinkAsync(out,
28    /// DBR_DOUBLE, &oval, 1)` with a synchronous `dbPutLink` fallback when the
29    /// link has no LSET (`devAoSoftCallback.c:41-54`), and `read_ai` returns 2
30    /// on every terminal path (`devAiSoftCallback.c:167-216`) after
31    /// `dbProcessNotify` has put the link's value straight into VAL. This port
32    /// applies the link synchronously, so the observable difference is PACT
33    /// timing, not the value.
34    Async,
35}
36
37/// The soft-channel flavour `dtyp` names, or `None` when it names device
38/// support that owns the transfer.
39///
40/// Only the four genuine base soft-channel DTYPs. Timestamp-producing DTYPs
41/// are NOT soft channels — they are real device support that writes a resolved
42/// time stamp into VAL, so they must reach the device-lookup path rather than
43/// short-circuit here:
44///  - "Soft Timestamp" (base `devTimestamp.c`) — served by the
45///    pre-registered `builtin_devices::builtin_dynamic_factory`.
46///  - "Sec Past Epoch" / "Time of Day" (epics-modules/std `devTimeOfDay.c`)
47///    — served by `std_rs::std_device_supports()`; if the IOC has not
48///    registered them they correctly warn as "no device support", not
49///    silently no-op as a soft channel. base-rs must not special-case a
50///    std-module DTYP (layering leak).
51pub fn classify_soft(dtyp: &str) -> Option<SoftDtyp> {
52    match dtyp {
53        "" | "Soft Channel" => Some(SoftDtyp::Plain),
54        "Raw Soft Channel" => Some(SoftDtyp::Raw),
55        "Async Soft Channel" => Some(SoftDtyp::Async),
56        _ => None,
57    }
58}
59
60/// Does this DTYP need no explicit device support registration?
61///
62/// The attach phase's question, and the only one that is genuinely yes/no:
63/// all three flavours are served by the framework, so none of them looks up a
64/// registered device.
65pub fn is_soft_dtyp(dtyp: &str) -> bool {
66    classify_soft(dtyp).is_some()
67}
68
69/// Handle for waiting on asynchronous write completion.
70/// Returned by [`DeviceSupport::write_begin`] when the write is submitted
71/// to a worker queue rather than executed synchronously.
72pub trait WriteCompletion: Send + 'static {
73    /// Block until the write completes or timeout expires.
74    fn wait(&self, timeout: std::time::Duration) -> CaResult<()>;
75}
76
77/// What a device support `read()` produced.
78///
79/// This is one half of C's dset contract; [`DeviceUdf`] is the other. C's
80/// `read_ai()` return value answers only "what did you write, and did you
81/// write anything":
82///
83/// ```c
84/// if (status==0) convert(prec);
85/// else if (status==2) status=0;
86/// if (status == 0) prec->udf = isnan(prec->val);
87/// ```
88///
89/// What the record does about `prec->udf` afterwards is the record's own rule
90/// and differs per type — `aiRecord.c:158-161` folds `2` into `0` before the
91/// UDF line and so re-derives on both, while `biRecord.c:136-141` keeps its
92/// assignment inside `if (status == 0)` and folds only afterwards, so a `2`
93/// never reaches it. That is not a contradiction: a C dset returning `2` has already
94/// written `prec->udf` itself (`devBiSoft.c:54-59`, `devBiDbState.c:67-70`,
95/// `devMbbiSoft.c:55-60`, `devTimestamp.c:40-41`). Port device support cannot reach
96/// `dbCommon`, so it states that fact through [`DeviceUdf`] instead and the
97/// framework applies it.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
99pub enum DeviceReadStatus {
100    /// C `return 0` — device support wrote RVAL. The record runs its built-in
101    /// conversion (ai: `ROFF → ASLO/AOFF → LINR/ESLO/EOFF → smoothing`).
102    #[default]
103    Converted,
104    /// C `return 2` — device support wrote VAL directly, so the record skips
105    /// its conversion and uses VAL as-is.
106    ///
107    /// **Common mistake:** returning [`Converted`](Self::Converted) when VAL is
108    /// set directly lets the conversion overwrite VAL from RVAL (typically 0),
109    /// making the read appear broken.
110    Computed,
111    /// C `return -1` and `return -2` — the read produced no value, so the
112    /// record's previous VAL stands and no conversion runs.
113    ///
114    /// The two C returns differ only in what the dset wrote to `prec->udf`
115    /// first — `processAiAverage` with `numAverage == 0` writes `prec->udf = 1`
116    /// and returns `-2` (`devAsynInt32.c:900-904`), its transport-error branch
117    /// writes nothing and returns `-1` (`:924-927`) — and at the record both
118    /// miss `if (status == 0)` identically. So the UDF half lives in
119    /// [`DeviceUdf`] and this variant carries only "nothing was produced";
120    /// keeping two value-variants that differed by a UDF fact was what let a
121    /// caller state a value outcome and a UDF outcome that disagreed.
122    NoValue,
123}
124
125impl DeviceReadStatus {
126    /// Whether the record must skip its built-in RVAL→VAL conversion.
127    ///
128    /// C runs `convert()` only for `return 0` (`aiRecord.c:159`).
129    pub fn skips_conversion(self) -> bool {
130        !matches!(self, Self::Converted)
131    }
132
133    /// Whether the read produced no value this cycle (C `-1` / `-2`).
134    pub fn read_failed(self) -> bool {
135        matches!(self, Self::NoValue)
136    }
137}
138
139/// What device support wrote to `prec->udf`, which in C the dset does itself.
140///
141/// Every C dset that returns `2` writes this first — `devBiSoft.c:54-59` and
142/// `devBiDbState.c:67-70` clear it, `devAsynInt32.c:900-904` sets it — and the
143/// record's `process()` may then overwrite it by its own rule. Port device
144/// support holds a `&mut dyn Record` and cannot reach `dbCommon`, so it says
145/// what it meant and the framework stays the single owner of the transition.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
147pub enum DeviceUdf {
148    /// The dset did not write `prec->udf`; the record's own rule decides.
149    #[default]
150    Untouched,
151    /// C `prec->udf = FALSE` — the value this read left in the record is
152    /// defined.
153    Defined,
154    /// C `prec->udf = TRUE` — the record's value is undefined.
155    Undefined,
156}
157
158/// Result of a device support `read()` call.
159///
160/// Carries what the read produced ([`DeviceReadStatus`]), what it said about
161/// UDF ([`DeviceUdf`]), and any side-effect actions (link writes, delayed
162/// reprocess) for the framework to execute.
163#[derive(Default)]
164pub struct DeviceReadOutcome {
165    /// Actions for the framework to execute (WriteDbLink, ReprocessAfter, etc.)
166    pub actions: Vec<ProcessAction>,
167    /// What the read produced — C's `read_ai()` return value.
168    pub status: DeviceReadStatus,
169    /// What the read said about `prec->udf`. Private so the two facts can only
170    /// be paired through the constructors below, which is what keeps "I wrote
171    /// VAL" from being stated without saying what that meant for UDF.
172    udf: DeviceUdf,
173}
174
175impl DeviceReadOutcome {
176    /// Device support wrote RVAL and said nothing about UDF; the record runs
177    /// its conversion and its own UDF rule.
178    ///
179    /// C equivalent: `read_ai()` returns 0 without touching `prec->udf`
180    /// (`devAiSoftRaw.c`).
181    pub fn ok() -> Self {
182        Self::default()
183    }
184
185    /// Device support wrote RVAL *and* wrote `prec->udf`.
186    ///
187    /// C equivalent: `devTimestamp.c:65-66` — `prec->udf = FALSE; return 0`.
188    pub fn converted(udf: DeviceUdf) -> Self {
189        Self {
190            status: DeviceReadStatus::Converted,
191            actions: Vec::new(),
192            udf,
193        }
194    }
195
196    /// Device support wrote VAL directly; the record skips its conversion.
197    ///
198    /// C equivalent: `read_ai()` returns 2. The [`DeviceUdf`] argument is not
199    /// optional because in C it is not: every dset that returns `2` has just
200    /// written `prec->udf`, and the record types whose `process()` leaves UDF
201    /// to the dset (`biRecord.c:136-141` and its mbbi / mbbiDirect / longin /
202    /// int64in twins) have nothing else to go on.
203    pub fn computed(udf: DeviceUdf) -> Self {
204        Self {
205            status: DeviceReadStatus::Computed,
206            actions: Vec::new(),
207            udf,
208        }
209    }
210
211    /// Shorthand for a computed read with actions.
212    pub fn computed_with(udf: DeviceUdf, actions: Vec<ProcessAction>) -> Self {
213        Self {
214            status: DeviceReadStatus::Computed,
215            actions,
216            udf,
217        }
218    }
219
220    /// The read produced no value; what happens to UDF is the argument.
221    pub fn no_value(udf: DeviceUdf) -> Self {
222        Self {
223            status: DeviceReadStatus::NoValue,
224            actions: Vec::new(),
225            udf,
226        }
227    }
228
229    /// The read produced no value and said nothing about UDF; the record's
230    /// previous VAL and UDF both stand.
231    ///
232    /// C equivalent: `read_ai()` returns -1.
233    pub fn failed() -> Self {
234        Self::no_value(DeviceUdf::Untouched)
235    }
236
237    /// The read produced no value and the record's value is undefined.
238    ///
239    /// C equivalent: `read_ai()` returns -2, which every reference user pairs
240    /// with `prec->udf = 1`.
241    pub fn undefined() -> Self {
242        Self::no_value(DeviceUdf::Undefined)
243    }
244
245    /// What this read said about `prec->udf`.
246    pub fn udf(&self) -> DeviceUdf {
247        self.udf
248    }
249
250    /// Whether the read declares the record's value undefined (C
251    /// `prec->udf = 1`).
252    pub fn asserts_undefined(&self) -> bool {
253        matches!(self.udf, DeviceUdf::Undefined)
254    }
255
256    /// Whether the record must skip its built-in conversion — true for every
257    /// status but [`DeviceReadStatus::Converted`].
258    pub fn did_compute(&self) -> bool {
259        self.status.skips_conversion()
260    }
261}
262
263/// Whether a device support's `init_record` left the record able to process.
264///
265/// C's `init_record` failure has two shapes and they are not the same record
266/// afterwards:
267///
268/// * `recGblRecordError(status, prec, ...); return status` — the record is
269///   flagged and still processes. `devBiDbState.c:28-31` rejects an illegal INP
270///   this way, `devGeneralTime.c:60-63` an illegal record type, and
271///   `iocInit.c::doInitRecord1` discards the status, so the record scans on.
272///   That is [`Err`] from [`DeviceSupport::init`].
273/// * the `bad:` arm — `pr->pact = 1; return -1`
274///   (`devAsynXXXTimeSeries.h:118-120`, and with a LINK_ALARM in
275///   `devAsynInt32.c:348-351` and its Float64/Int64/UInt32Digital twins). PACT
276///   is set at init and nothing ever clears it, so `dbProcess` takes its
277///   already-active branch (`dbAccess.c:536-556`) on every later entry: the
278///   record is DEAD. That is [`Dead`](Self::Dead).
279///
280/// The distinction is user-visible, which is why it needs a type rather than a
281/// severity: a dead waveform reads PACT=1 and BUSY=0 forever, `caput REC.RARM 1`
282/// sets RPRO and processes nothing (`dbAccess.c:1267-1271`), and a *scanned*
283/// dead record collects SCAN_ALARM/INVALID once `lcnt` passes MAX_LOCK.
284#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
285pub enum DeviceInitOutcome {
286    /// C `return INIT_OK` / `return 0` — the record processes normally.
287    #[default]
288    Live,
289    /// C's `bad:` arm — `pr->pact = 1`. The framework sets PACT and never
290    /// releases it, so the record never processes again.
291    ///
292    /// Device support prints its own diagnostic before returning this, exactly
293    /// as C `errlogPrintf`s before `goto bad`. The alarm rides along because the
294    /// C arms do not agree on one and none of them can be reconstructed later:
295    /// `devAsynXXXTimeSeries.h:118-120` raises none, while `devAsynInt32.c:348-351`
296    /// and `devAsynOctet.c::initCmdBuffer:632-636` call `recGblSetSevr(precord,
297    /// LINK_ALARM, INVALID_ALARM)` on the record itself, at init, next to the
298    /// `pact = 1`. A dead record never processes, so a per-read alarm channel
299    /// can never deliver it — it has to be applied here or not at all.
300    ///
301    /// Build with [`DeviceInitOutcome::dead`] or
302    /// [`DeviceInitOutcome::dead_with_alarm`].
303    Dead {
304        /// `(STAT, SEVR)` for the `recGblSetSevr` that precedes C's `pact = 1`,
305        /// or `None` for a `bad:` arm that raises nothing.
306        alarm: Option<(u16, AlarmSeverity)>,
307    },
308}
309
310impl DeviceInitOutcome {
311    /// C's bare `bad:` arm — `pr->pact = 1` with no `recGblSetSevr`
312    /// (`devAsynXXXTimeSeries.h:118-120`).
313    pub fn dead() -> Self {
314        Self::Dead { alarm: None }
315    }
316
317    /// C's `bad:` arm with the `recGblSetSevr` that precedes it
318    /// (`devAsynInt32.c:348-351`: `LINK_ALARM` / `INVALID_ALARM`).
319    pub fn dead_with_alarm(stat: u16, sevr: AlarmSeverity) -> Self {
320        Self::Dead {
321            alarm: Some((stat, sevr)),
322        }
323    }
324}
325
326/// One out-of-band PROPERTY post from a device support: the fields it writes
327/// and the one field it posts on.
328///
329/// The two are deliberately different sets. C's enum re-propagation callbacks
330/// (`devAsynInt32.c:712-766`, `devAsynUInt32Digital.c:547-601`, asyn
331/// `e2a281e2`) are three statements under one `dbScanLock`:
332///
333/// ```c
334/// setEnums((char*)&pr->zrst, (int*)&pr->zrvl, &pr->zrsv, ...);
335/// db_post_events(pr, &pr->val, DBE_PROPERTY);
336/// ```
337///
338/// `setEnums` rewrites ZRST/ZRVL/ZRSV… in place and posts on none of them;
339/// the single `db_post_events` names `&pr->val`, so it is the client
340/// monitoring the PV itself that learns the choices moved and re-reads
341/// `DBR_GR_ENUM`. Collapsing the two sets into one field list would post
342/// every state field and nothing on VAL — the opposite of both halves.
343///
344/// Measured end to end against C `libca` clients (R7.0.10 host build), not
345/// just at this layer — the `mbbi_enum_property_ioc` example in `epics-ca-rs`
346/// is the IOC half. On the post, a `DBR_GR_ENUM` + `DBE_PROPERTY`
347/// subscription (base's own attribute-re-read shape, `dbCa.c`) receives a
348/// second event carrying `["OFF","ON","FAULT"]` with `value` unmoved at 1,
349/// and `camonitor -m p` re-renders `One` as `ON`. A `camonitor -m va` on the
350/// same record sees only its initial event, which is the discrimination this
351/// type exists for: re-keyed labels are not a new reading.
352#[derive(Debug, Clone)]
353pub struct PropertyPost {
354    /// Fields to store without posting.
355    pub writes: Vec<(String, crate::types::EpicsValue)>,
356    /// The field `db_post_events` names, posted `DBE_PROPERTY` after the
357    /// writes land, under the same record lock.
358    pub post_field: String,
359}
360
361/// Trait for custom device support implementations.
362/// When DTYP is set to something other than "" or "Soft Channel",
363/// the registered DeviceSupport is used instead of link resolution.
364pub trait DeviceSupport: Send + Sync + 'static {
365    /// C `init_record`. See [`DeviceInitOutcome`] for the two failure shapes:
366    /// `Err` flags the record but leaves it processing, `Ok(Dead)` is C's
367    /// `pr->pact = 1` and stops it for good.
368    fn init(&mut self, _record: &mut dyn Record) -> CaResult<DeviceInitOutcome> {
369        Ok(DeviceInitOutcome::Live)
370    }
371
372    /// Read from hardware into the record.
373    ///
374    /// Returns a `DeviceReadOutcome` containing:
375    /// - `actions`: side-effect actions (link writes, delayed reprocess)
376    ///   that the framework will execute after process()
377    /// - `did_compute`: if true, the record's built-in compute was already
378    ///   performed (e.g., device support ran PID), so process() should skip it
379    fn read(&mut self, record: &mut dyn Record) -> CaResult<DeviceReadOutcome> {
380        let _ = record;
381        Ok(DeviceReadOutcome::ok())
382    }
383
384    fn write(&mut self, record: &mut dyn Record) -> CaResult<()>;
385    fn dtyp(&self) -> &str;
386
387    /// Return the last alarm (status, severity) from the driver.
388    /// None means the driver does not override alarms.
389    fn last_alarm(&self) -> Option<(u16, u16)> {
390        None
391    }
392
393    /// Return the last timestamp from the driver.
394    /// None means the driver does not override timestamps.
395    fn last_timestamp(&self) -> Option<std::time::SystemTime> {
396        None
397    }
398
399    /// Return the userTag the driver attached to its reading, as the
400    /// 64-bit `epicsUTag`. `None` means the driver provides no userTag
401    /// and `common.utag` is left untouched.
402    ///
403    /// This is the channel a timing receiver (event system) uses to
404    /// deliver a pulse-id / event tag: `epicsTimeStamp` itself carries
405    /// no tag and the generalTime event path (`epicsTimeGetEvent`)
406    /// delivers only the timestamp, so the tag must come through device
407    /// support — mirroring C device support writing `prec->utag`
408    /// directly during `read()` (alongside `prec->time`, TSE=-2).
409    fn last_utag(&self) -> Option<u64> {
410        None
411    }
412
413    /// Called by the framework immediately before [`read()`](DeviceSupport::read)
414    /// to push a read-only snapshot of framework-owned `CommonFields`
415    /// state ([`crate::server::record::ProcessContext`]) that the device
416    /// support needs.
417    ///
418    /// `read()` receives only `&mut dyn Record`; it cannot reach
419    /// `RecordInstance.common`. C device support reads `dbCommon`
420    /// directly — `devTimeOfDay.c:122` selects its time format from
421    /// `psi->phas`. A driver that needs `phas`/`udf`/`tse`/`tsel`
422    /// overrides this to stash the values before `read()` runs.
423    ///
424    /// Additive framework-set-hook (same shape as
425    /// [`DeviceSupport::set_record_info`]). Default: ignore.
426    fn set_process_context(&mut self, _ctx: &crate::server::record::ProcessContext) {}
427
428    /// Called after init() with the record name and scan type.
429    fn set_record_info(&mut self, _name: &str, _scan: ScanType) {}
430
431    /// Forward parsed `info("key", "value")` directives from the .db
432    /// file to the device support. Default is a no-op; drivers that
433    /// react to specific tags (asyn `asyn:READBACK`, EtherCAT terminal
434    /// hints, etc.) override this. Called once after `set_record_info`
435    /// during builder wiring; not called again at runtime.
436    fn apply_record_info(&mut self, _info: &std::collections::HashMap<String, String>) {}
437
438    /// Return a receiver for I/O Intr scan notifications.
439    /// Called for records with `SCAN="I/O Intr"`, and for any device that
440    /// reports [`io_intr_scan_independent`](Self::io_intr_scan_independent).
441    fn io_intr_receiver(&mut self) -> Option<crate::runtime::sync::mpsc::Receiver<()>> {
442        None
443    }
444
445    /// Return a receiver of out-of-band PROPERTY-class field posts.
446    ///
447    /// C parity: `registerInterruptUser(callbackEnum)` (devAsynInt32.c:319)
448    /// plus the per-record enum callback
449    /// (`interruptCallbackEnumMbbi`/`…Bi`, devAsynInt32.c:712-766), which
450    /// calls `setEnums` to re-key the record's state strings/values/
451    /// severities and then `db_post_events(precord, &precord->val,
452    /// DBE_PROPERTY)` so CA/PVA clients re-read the enum choices. This is
453    /// driven by the driver's `doCallbacksEnum`, independent of the
454    /// record's `SCAN` (it is not a value scan, so it does not process the
455    /// record).
456    ///
457    /// Each delivered message is one [`PropertyPost`]: the field block to
458    /// write (the C `setEnums` block) and, separately, the single field
459    /// `db_post_events` names. The framework drains this receiver and calls
460    /// [`crate::server::database::PvDatabase::post_property`].
461    /// Mirrors [`io_intr_receiver`](Self::io_intr_receiver): the device owns
462    /// the source subscription, the framework owns the post. Default:
463    /// `None` (device drives no property posts).
464    fn property_post_receiver(
465        &mut self,
466    ) -> Option<crate::runtime::sync::mpsc::Receiver<PropertyPost>> {
467        None
468    }
469
470    /// Whether this device drives record processing from its own callback
471    /// channel independently of the runtime `SCAN` menu.
472    ///
473    /// C parity: a `motorRecord` device callback (`statusCallback`) does its
474    /// own `dbScanLock` + `dbProcess` on every poll readback regardless of
475    /// `SCAN`, and the record stays `SCAN="Passive"` so a `dbPutField` to a
476    /// `pp(TRUE)` field (VAL/DVAL/...) still re-processes it
477    /// (`dbAccess.c:1263-1268`). asyn readback records behave the same way
478    /// (upstream PRs #60/#208 — output records follow driver-side changes
479    /// regardless of `SCAN`).
480    ///
481    /// When `true`, the I/O Intr wiring processes the record on every pulse
482    /// even when `SCAN != "I/O Intr"`. When `false` (default), processing is
483    /// gated on the record's current `SCAN` being `"I/O Intr"`, matching C
484    /// `scanIoRequest`, which honors scan-list membership.
485    fn io_intr_scan_independent(&self) -> bool {
486        false
487    }
488
489    /// Arm an output driver-callback (`asyn:READBACK`) cycle.
490    ///
491    /// Called by [`crate::server::database::PvDatabase::process_record_readback`]
492    /// immediately before the processing pass, mirroring C
493    /// `devAsynInt32.c::outputCallbackCallback` setting
494    /// `newOutputCallbackValue = 1` before `dbProcess`. Pair with
495    /// [`Self::reconcile_readback_callback`]: if the pass never reaches the
496    /// device read stage (the PACT entry guard bails because a put / FLNK
497    /// cycle still owns the record), the armed flag survives and reconcile
498    /// discards the stale callback-ring entry so a callback ring never
499    /// desyncs from the record's pop count. Default no-op — only output
500    /// callback-driven device support (asyn readback) needs it.
501    fn arm_readback_callback(&mut self) {}
502
503    /// Reconcile an armed output driver-callback cycle after processing.
504    ///
505    /// C `outputCallbackCallback` fallback (devEpics `devAsynInt32.c`): after
506    /// `dbProcess`, if `newOutputCallbackValue` is still set the record did
507    /// not process, so `getCallbackValue` is called to drop the stale ring
508    /// entry. Default no-op; see [`Self::arm_readback_callback`].
509    fn reconcile_readback_callback(&mut self) {}
510
511    /// Whether a driver-callback (`asyn:READBACK`) processing cycle replaces
512    /// this device's output stage with a value readback.
513    ///
514    /// C devEpics (`devAsynInt32.c::processAo`/`processBo`/…) takes the
515    /// `newOutputCallbackValue` readback branch on a callback cycle and never
516    /// calls the output `write()` — re-writing would re-assert the setpoint
517    /// and re-trigger the driver (the AD `Acquire` loop). Only device support
518    /// implementing that contract (the [`Self::arm_readback_callback`] /
519    /// [`Self::reconcile_readback_callback`] pair) returns `true`.
520    ///
521    /// Default `false`: a C `dbProcess` driven by a driver callback is a full
522    /// record process, output stage included. `devMotorAsyn` has no readback
523    /// suppression — the motor record dispatches its retry, backlash-leg,
524    /// NTM-stop, and queued-motion-resume commands from exactly these
525    /// CALLBACK_DATA passes, and suppressing the write strands them in the
526    /// command mailbox (DMOV stuck 0, MIP=RETRY|MOVE, later puts time out).
527    fn output_callback_readback(&self) -> bool {
528        false
529    }
530
531    /// Begin an asynchronous write (submit only, no blocking).
532    /// Returns `Some(handle)` if the write was submitted to a worker queue —
533    /// the caller should wait on the handle outside any record lock.
534    /// Returns `None` to fall back to synchronous [`write()`](DeviceSupport::write).
535    fn write_begin(
536        &mut self,
537        _record: &mut dyn Record,
538    ) -> CaResult<Option<Box<dyn WriteCompletion>>> {
539        Ok(None)
540    }
541
542    /// Handle a named command from the record's process() via
543    /// `ProcessAction::DeviceCommand`. This allows records to request
544    /// driver operations (e.g., scaler reset/arm/write_preset) without
545    /// holding a direct driver reference.
546    ///
547    /// `handle_command` runs AFTER the process snapshot has already been
548    /// built and notified, so any record field it mutates would not be
549    /// diffed by the snapshot path. The returned `Vec` names the record
550    /// fields the command changed; the framework posts a `DBE_VALUE`
551    /// monitor event for each, mirroring the explicit `db_post_events`
552    /// calls a C record makes from inside `process()` (e.g.
553    /// `scalerRecord.c:425-430` posts PR1/TP/FREQ after the driver
554    /// write-back). Return an empty `Vec` when no record field changed.
555    ///
556    /// Default: ignore, no fields changed.
557    fn handle_command(
558        &mut self,
559        _record: &mut dyn Record,
560        _command: &str,
561        _args: &[crate::types::EpicsValue],
562    ) -> CaResult<Vec<&'static str>> {
563        Ok(Vec::new())
564    }
565}
566
567/// Canonical device-support init sequence — the single owner of the
568/// "attach device support to a record" contract.
569///
570/// Both build paths (`crate::server::ioc_app::wire_device_support`
571/// and [`crate::server::ioc_builder::IocBuilder::build`]) MUST call
572/// this so a driver author can write one correct `init()`.
573///
574/// Order (C parity — `recGblInitConstantLink`-style field setup runs
575/// before `init_record`; `set_record_info` / `apply_record_info` are
576/// Rust extensions that supply that field context and therefore
577/// precede `init`):
578///
579/// 1. `set_record_info(name, scan)` — give the driver its record
580///    identity and scan mode.
581/// 2. `apply_record_info(info)` — forward `info(...)` tags so a
582///    driver that reads them inside `init()` sees a populated map.
583/// 3. `init(record)` — driver `init_record` equivalent.
584///
585/// On `init()` failure the record is flagged `INVALID` severity with
586/// a `SOFT` status and a diagnostic is logged, so the failure is
587/// observable rather than silently attached as healthy.
588///
589/// On [`DeviceInitOutcome::Dead`] this is also the single owner of
590/// C's `pr->pact = 1` (`devAsynXXXTimeSeries.h:118-120`): the record
591/// enters PACT here and nothing releases it, because the only release
592/// is [`RecordInstance::leave_pact`] at a process-cycle tail and
593/// `dbProcess`'s already-active guard turns every entry away before
594/// the cycle starts. Device support cannot reach `dbCommon` through
595/// the `&mut dyn Record` it holds, so it says *dead* and the framework
596/// performs the transition — the same split as
597/// [`DeviceUdf::Undefined`] and the UDF assertion.
598///
599/// Success clears NOTHING. In C, whether an `init_record` defines the
600/// record is a property of the individual dset, not of the framework:
601/// `devTimestamp.c` declares no `init_record` at all and clears
602/// `prec->udf` only inside `read_ai`/`read_stringin` (`:40`, `:65`),
603/// and `iocInit.c::doInitRecord0` (`:508-533`) only READS `udf` to
604/// derive the initial severity. A record whose device support has
605/// produced no value is still undefined, and says so.
606///
607/// The device is attached (`instance.device = Some(dev)`) regardless
608/// of init outcome so the record is addressable; a failed init leaves
609/// the alarm set.
610pub fn wire_device_to_record(instance: &mut RecordInstance, dev: Box<dyn DeviceSupport>) {
611    attach_device_to_record(instance, dev);
612    init_device_support(instance);
613}
614
615/// The BIND half of [`wire_device_to_record`]: C `iocInit.c::doInitRecord0`'s
616/// `precord->dset = pdevSup ? pdevSup->pdset : NULL` (`:530-533`), which
617/// happens BEFORE `prset->init_record(precord, 0)` and runs no driver code.
618///
619/// Split from the init half because C's two halves sit on opposite sides of
620/// `init_record`: every `<rec>Record.c init_record` opens by testing the dset
621/// this line bound (`if (!pdset) return S_dev_noDSET`) and only later calls
622/// `pdset->common.init_record`. Attaching and initialising in one step is what
623/// put the whole sequence after the record's init passes, so `init_record`
624/// could not see its own dset and ran the tail C's early return skips.
625pub fn attach_device_to_record(instance: &mut RecordInstance, mut dev: Box<dyn DeviceSupport>) {
626    let name = instance.name.clone();
627    dev.set_record_info(&name, instance.common.scan);
628    dev.apply_record_info(&instance.info);
629    instance.device = Some(dev);
630}
631
632/// The INIT half: C `pdset->common.init_record(prec)`, the call every record
633/// type makes from inside its own `init_record` once the dset test above has
634/// passed (`aiRecord.c:115-124`).
635///
636/// No-op for a record that has no device attached — C reaches this line only
637/// past `if (!pdset) return S_dev_noDSET`.
638pub fn init_device_support(instance: &mut RecordInstance) {
639    let Some(mut dev) = instance.device.take() else {
640        return;
641    };
642    let name = instance.name.clone();
643    match dev.init(&mut *instance.record) {
644        Ok(DeviceInitOutcome::Live) => {}
645        Ok(DeviceInitOutcome::Dead { alarm }) => {
646            // C's `bad:` arm. The driver has already printed why. The
647            // `recGblSetSevr` comes first in every C arm that has one, so it
648            // runs before PACT here too — and through the same helper, so a
649            // record already carrying a higher severity keeps it.
650            if let Some((stat, sevr)) = alarm {
651                crate::server::recgbl::rec_gbl_set_sevr(&mut instance.common, stat, sevr);
652            }
653            instance.enter_pact();
654        }
655        Err(e) => {
656            eprintln!(
657                "device support init failed for record '{name}' (DTYP '{}'): {e}",
658                instance.common.dtyp
659            );
660            // Flag the record so the failure is observable rather
661            // than presenting a healthy-looking record.
662            instance.common.sevr = AlarmSeverity::Invalid;
663            instance.common.stat = crate::server::recgbl::alarm_status::SOFT_ALARM;
664        }
665    }
666    instance.device = Some(dev);
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use crate::error::CaError;
673    use crate::server::record::{AlarmSeverity, Record, RecordInstance, ScanType};
674    use crate::server::records::ai::AiRecord;
675    use std::collections::HashMap;
676    use std::sync::{Arc, Mutex};
677
678    /// Observed wiring state, shared with the test via `Arc` so it is
679    /// inspectable after the device is moved into the record.
680    #[derive(Default)]
681    struct WireObservation {
682        /// Info keys visible to `init()`.
683        info_at_init: Vec<String>,
684        /// Whether `set_record_info` ran before `init()`.
685        record_info_before_init: bool,
686        /// Whether `set_record_info` had run by the time `init` ran.
687        init_ran: bool,
688    }
689
690    /// What the probe's `init` returns — the three C shapes.
691    #[derive(Clone, Copy)]
692    enum InitVerdict {
693        /// C `return INIT_OK`.
694        Live,
695        /// C's `bad:` arm — `pr->pact = 1`.
696        Dead,
697        /// C `recGblRecordError(status, ...); return status`, PACT untouched.
698        Fail,
699    }
700
701    /// Device support that records the wiring order and returns `verdict`
702    /// from `init`.
703    struct ProbeDev {
704        obs: Arc<Mutex<WireObservation>>,
705        info: HashMap<String, String>,
706        record_info_set: bool,
707        verdict: InitVerdict,
708    }
709    impl DeviceSupport for ProbeDev {
710        fn dtyp(&self) -> &str {
711            "ProbeDev"
712        }
713        fn write(&mut self, _record: &mut dyn Record) -> CaResult<()> {
714            Ok(())
715        }
716        fn set_record_info(&mut self, _name: &str, _scan: ScanType) {
717            self.record_info_set = true;
718        }
719        fn apply_record_info(&mut self, info: &HashMap<String, String>) {
720            self.info = info.clone();
721        }
722        fn init(&mut self, _record: &mut dyn Record) -> CaResult<DeviceInitOutcome> {
723            let mut o = self.obs.lock().unwrap();
724            o.init_ran = true;
725            o.record_info_before_init = self.record_info_set;
726            o.info_at_init = self.info.keys().cloned().collect();
727            match self.verdict {
728                InitVerdict::Live => Ok(DeviceInitOutcome::Live),
729                InitVerdict::Dead => Ok(DeviceInitOutcome::dead()),
730                InitVerdict::Fail => Err(CaError::InvalidValue("device init failed".into())),
731            }
732        }
733    }
734
735    /// Wire a probe with `verdict` onto a fresh ai and hand back the instance.
736    fn wire(verdict: InitVerdict) -> RecordInstance {
737        let mut instance = RecordInstance::new("TEST:DEAD".to_string(), AiRecord::new(0.0));
738        instance.common.dtyp = "ProbeDev".to_string();
739        wire_device_to_record(
740            &mut instance,
741            Box::new(ProbeDev {
742                obs: Arc::new(Mutex::new(WireObservation::default())),
743                info: HashMap::new(),
744                record_info_set: false,
745                verdict,
746            }),
747        );
748        instance
749    }
750
751    /// C's `bad:` arm sets `pr->pact = 1` and nothing ever clears it, so
752    /// `dbProcess` takes its already-active branch forever
753    /// (`devAsynXXXTimeSeries.h:118-120`, `dbAccess.c:536`). The port had no way
754    /// for device support to reach that state: an invalid FTVL left the driver
755    /// returning an inert `read()` while the record kept processing with PACT=0.
756    #[test]
757    fn wire_device_dead_init_leaves_the_record_in_pact() {
758        let instance = wire(InitVerdict::Dead);
759
760        assert!(
761            instance.is_processing(),
762            "C `bad: pr->pact = 1` — the record must be dead"
763        );
764        assert!(
765            instance.device.is_some(),
766            "a dead record is still addressable; only processing stops"
767        );
768    }
769
770    /// The framework must not invent an alarm for the dead arm: the
771    /// `devAsynXXXTimeSeries.h` `bad:` label raises none (its whole body is
772    /// `pr->pact=1; return -1`), and the arms that do raise one
773    /// (`devAsynInt32.c:349` LINK_ALARM) do it from device support, through the
774    /// driver's own alarm channel.
775    #[test]
776    fn wire_device_dead_init_raises_no_alarm_of_its_own() {
777        let dead = wire(InitVerdict::Dead);
778        let live = wire(InitVerdict::Live);
779
780        assert_eq!(dead.common.sevr, live.common.sevr);
781        assert_eq!(
782            dead.common.stat, live.common.stat,
783            "the dead arm leaves STAT at whatever the record was born with"
784        );
785    }
786
787    /// The other two verdicts leave the record processing. `Err` is C's
788    /// `recGblRecordError(status, prec, ...); return status` with PACT untouched
789    /// (`devBiDbState.c:28-31`, `devGeneralTime.c:60-63`) — a flagged record
790    /// still scans, which is why the dead arm needed a value of its own rather
791    /// than folding into the error channel.
792    #[test]
793    fn wire_device_live_and_failed_inits_leave_the_record_processing() {
794        assert!(!wire(InitVerdict::Live).is_processing());
795        assert!(
796            !wire(InitVerdict::Fail).is_processing(),
797            "an errored init flags the record but must not kill it"
798        );
799    }
800
801    /// M2 regression: a device support whose `init()` returns `Err`
802    /// must NOT be attached as a healthy record — the record is
803    /// flagged INVALID severity with a SOFT status. (Pre-fix the
804    /// IocBuilder path discarded the error with `let _ =`.)
805    #[test]
806    fn wire_device_init_failure_flags_record_invalid() {
807        let mut instance = RecordInstance::new("TEST:AI".to_string(), AiRecord::new(0.0));
808        instance.common.dtyp = "ProbeDev".to_string();
809        let obs = Arc::new(Mutex::new(WireObservation::default()));
810        let dev = Box::new(ProbeDev {
811            obs: obs.clone(),
812            info: HashMap::new(),
813            record_info_set: false,
814            verdict: InitVerdict::Fail,
815        });
816
817        wire_device_to_record(&mut instance, dev);
818
819        assert_eq!(
820            instance.common.sevr,
821            AlarmSeverity::Invalid,
822            "failed device init must flag the record INVALID"
823        );
824        assert_eq!(
825            instance.common.stat,
826            crate::server::recgbl::alarm_status::SOFT_ALARM,
827        );
828        assert!(
829            instance.device.is_some(),
830            "device is still attached so the record is addressable"
831        );
832    }
833
834    /// M1 regression: the canonical wiring order is
835    /// set_record_info → apply_record_info → init. A driver reading
836    /// `info(...)` tags inside `init()` must see a populated map, and
837    /// `set_record_info` must have run first.
838    #[test]
839    fn wire_device_applies_info_and_record_info_before_init() {
840        let mut instance = RecordInstance::new("TEST:AI2".to_string(), AiRecord::new(0.0));
841        instance.common.dtyp = "ProbeDev".to_string();
842        instance.set_info("asyn:READBACK", "1");
843        let obs = Arc::new(Mutex::new(WireObservation::default()));
844        let dev = Box::new(ProbeDev {
845            obs: obs.clone(),
846            info: HashMap::new(),
847            record_info_set: false,
848            verdict: InitVerdict::Live,
849        });
850
851        wire_device_to_record(&mut instance, dev);
852
853        let o = obs.lock().unwrap();
854        assert!(o.init_ran, "init must have run");
855        assert!(
856            o.record_info_before_init,
857            "set_record_info must run before init"
858        );
859        assert!(
860            o.info_at_init.iter().any(|k| k == "asyn:READBACK"),
861            "info(...) tags must be visible inside init()"
862        );
863    }
864}