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