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