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/// Check if a DTYP string represents a soft/built-in device support
5/// that doesn't require an explicit device support registration.
6/// Matches C EPICS built-in soft device support names.
7pub fn is_soft_dtyp(dtyp: &str) -> bool {
8    // Only the four genuine base soft-channel DTYPs. Timestamp-producing
9    // DTYPs are NOT soft channels — they are real device support that writes
10    // a resolved time stamp into VAL, so they must reach the device-lookup
11    // path rather than short-circuit here:
12    //  - "Soft Timestamp" (base `devTimestamp.c`) — served by the
13    //    pre-registered `builtin_devices::builtin_dynamic_factory`.
14    //  - "Sec Past Epoch" / "Time of Day" (epics-modules/std `devTimeOfDay.c`)
15    //    — served by `std_rs::std_device_supports()`; if the IOC has not
16    //    registered them they correctly warn as "no device support", not
17    //    silently no-op as a soft channel. base-rs must not special-case a
18    //    std-module DTYP (layering leak).
19    dtyp.is_empty()
20        || dtyp == "Soft Channel"
21        || dtyp == "Raw Soft Channel"
22        || dtyp == "Async Soft Channel"
23}
24
25/// Handle for waiting on asynchronous write completion.
26/// Returned by [`DeviceSupport::write_begin`] when the write is submitted
27/// to a worker queue rather than executed synchronously.
28pub trait WriteCompletion: Send + 'static {
29    /// Block until the write completes or timeout expires.
30    fn wait(&self, timeout: std::time::Duration) -> CaResult<()>;
31}
32
33/// Outcome of a device support read() call.
34///
35/// Allows device support to return side-effect actions (link writes,
36/// delayed reprocess) and signal that it has already performed the
37/// Result of a device support `read()` call.
38///
39/// # `ok()` vs `computed()`
40///
41/// This mirrors the C EPICS `read_ai()` return convention:
42///
43/// - **`ok()`** (C return 0): Device support wrote to RVAL. The record's
44///   `process()` will run its built-in conversion (e.g., ai applies
45///   `ROFF → ASLO/AOFF → LINR/ESLO/EOFF → smoothing` to produce VAL
46///   from RVAL).
47///
48/// - **`computed()`** (C return 2): Device support wrote to VAL directly.
49///   The record's `process()` will **skip** its conversion and use the
50///   VAL as-is. Use this when the device support provides engineering
51///   units directly (e.g., soft channel, asyn, custom drivers that
52///   call `record.put_field("VAL", ...)`).
53///
54/// **Common mistake:** returning `ok()` when VAL is set directly causes
55/// the record's conversion to overwrite VAL with a value derived from
56/// RVAL (typically 0), making the read appear broken.
57#[derive(Default)]
58pub struct DeviceReadOutcome {
59    /// Actions for the framework to execute (WriteDbLink, ReprocessAfter, etc.)
60    pub actions: Vec<ProcessAction>,
61    /// If true, the record's built-in conversion (e.g., ai RVAL→VAL)
62    /// is skipped. Set this when device support writes VAL directly.
63    pub did_compute: bool,
64}
65
66impl DeviceReadOutcome {
67    /// Device support wrote RVAL; record will run its conversion to produce VAL.
68    ///
69    /// C equivalent: `read_ai()` returns 0.
70    pub fn ok() -> Self {
71        Self::default()
72    }
73
74    /// Device support wrote VAL directly; record will skip conversion.
75    ///
76    /// C equivalent: `read_ai()` returns 2.
77    pub fn computed() -> Self {
78        Self {
79            did_compute: true,
80            actions: Vec::new(),
81        }
82    }
83
84    /// Shorthand for a computed read with actions.
85    pub fn computed_with(actions: Vec<ProcessAction>) -> Self {
86        Self {
87            did_compute: true,
88            actions,
89        }
90    }
91}
92
93/// Trait for custom device support implementations.
94/// When DTYP is set to something other than "" or "Soft Channel",
95/// the registered DeviceSupport is used instead of link resolution.
96pub trait DeviceSupport: Send + Sync + 'static {
97    fn init(&mut self, _record: &mut dyn Record) -> CaResult<()> {
98        Ok(())
99    }
100
101    /// Read from hardware into the record.
102    ///
103    /// Returns a `DeviceReadOutcome` containing:
104    /// - `actions`: side-effect actions (link writes, delayed reprocess)
105    ///   that the framework will execute after process()
106    /// - `did_compute`: if true, the record's built-in compute was already
107    ///   performed (e.g., device support ran PID), so process() should skip it
108    fn read(&mut self, record: &mut dyn Record) -> CaResult<DeviceReadOutcome> {
109        let _ = record;
110        Ok(DeviceReadOutcome::ok())
111    }
112
113    fn write(&mut self, record: &mut dyn Record) -> CaResult<()>;
114    fn dtyp(&self) -> &str;
115
116    /// Return the last alarm (status, severity) from the driver.
117    /// None means the driver does not override alarms.
118    fn last_alarm(&self) -> Option<(u16, u16)> {
119        None
120    }
121
122    /// Return the last timestamp from the driver.
123    /// None means the driver does not override timestamps.
124    fn last_timestamp(&self) -> Option<std::time::SystemTime> {
125        None
126    }
127
128    /// Return the userTag the driver attached to its reading, as the
129    /// 64-bit `epicsUTag`. `None` means the driver provides no userTag
130    /// and `common.utag` is left untouched.
131    ///
132    /// This is the channel a timing receiver (event system) uses to
133    /// deliver a pulse-id / event tag: `epicsTimeStamp` itself carries
134    /// no tag and the generalTime event path (`epicsTimeGetEvent`)
135    /// delivers only the timestamp, so the tag must come through device
136    /// support — mirroring C device support writing `prec->utag`
137    /// directly during `read()` (alongside `prec->time`, TSE=-2).
138    fn last_utag(&self) -> Option<u64> {
139        None
140    }
141
142    /// Called by the framework immediately before [`read()`](DeviceSupport::read)
143    /// to push a read-only snapshot of framework-owned `CommonFields`
144    /// state ([`crate::server::record::ProcessContext`]) that the device
145    /// support needs.
146    ///
147    /// `read()` receives only `&mut dyn Record`; it cannot reach
148    /// `RecordInstance.common`. C device support reads `dbCommon`
149    /// directly — `devTimeOfDay.c:122` selects its time format from
150    /// `psi->phas`. A driver that needs `phas`/`udf`/`tse`/`tsel`
151    /// overrides this to stash the values before `read()` runs.
152    ///
153    /// Additive framework-set-hook (same shape as
154    /// [`DeviceSupport::set_record_info`]). Default: ignore.
155    fn set_process_context(&mut self, _ctx: &crate::server::record::ProcessContext) {}
156
157    /// Called after init() with the record name and scan type.
158    fn set_record_info(&mut self, _name: &str, _scan: ScanType) {}
159
160    /// Forward parsed `info("key", "value")` directives from the .db
161    /// file to the device support. Default is a no-op; drivers that
162    /// react to specific tags (asyn `asyn:READBACK`, EtherCAT terminal
163    /// hints, etc.) override this. Called once after `set_record_info`
164    /// during builder wiring; not called again at runtime.
165    fn apply_record_info(&mut self, _info: &std::collections::HashMap<String, String>) {}
166
167    /// Return a receiver for I/O Intr scan notifications.
168    /// Called for records with `SCAN="I/O Intr"`, and for any device that
169    /// reports [`io_intr_scan_independent`](Self::io_intr_scan_independent).
170    fn io_intr_receiver(&mut self) -> Option<crate::runtime::sync::mpsc::Receiver<()>> {
171        None
172    }
173
174    /// Return a receiver of out-of-band PROPERTY-class field posts.
175    ///
176    /// C parity: `registerInterruptUser(callbackEnum)` (devAsynInt32.c:319)
177    /// plus the per-record enum callback
178    /// (`interruptCallbackEnumMbbi`/`…Bi`, devAsynInt32.c:711-762), which
179    /// calls `setEnums` to re-key the record's state strings/values/
180    /// severities and then `db_post_events(precord, &precord->val,
181    /// DBE_PROPERTY)` so CA/PVA clients re-read the enum choices. This is
182    /// driven by the driver's `doCallbacksEnum`, independent of the
183    /// record's `SCAN` (it is not a value scan, so it does not process the
184    /// record).
185    ///
186    /// Each delivered message is the full `(field, value)` set to
187    /// write-and-post (the C `setEnums` field block). The framework drains
188    /// this receiver and calls
189    /// [`crate::server::database::PvDatabase::post_property_fields`].
190    /// Mirrors [`io_intr_receiver`](Self::io_intr_receiver): the device owns
191    /// the source subscription, the framework owns the post. Default:
192    /// `None` (device drives no property posts).
193    fn property_post_receiver(
194        &mut self,
195    ) -> Option<crate::runtime::sync::mpsc::Receiver<Vec<(String, crate::types::EpicsValue)>>> {
196        None
197    }
198
199    /// Whether this device drives record processing from its own callback
200    /// channel independently of the runtime `SCAN` menu.
201    ///
202    /// C parity: a `motorRecord` device callback (`statusCallback`) does its
203    /// own `dbScanLock` + `dbProcess` on every poll readback regardless of
204    /// `SCAN`, and the record stays `SCAN="Passive"` so a `dbPutField` to a
205    /// `pp(TRUE)` field (VAL/DVAL/...) still re-processes it
206    /// (`dbAccess.c:1263-1268`). asyn readback records behave the same way
207    /// (upstream PRs #60/#208 — output records follow driver-side changes
208    /// regardless of `SCAN`).
209    ///
210    /// When `true`, the I/O Intr wiring processes the record on every pulse
211    /// even when `SCAN != "I/O Intr"`. When `false` (default), processing is
212    /// gated on the record's current `SCAN` being `"I/O Intr"`, matching C
213    /// `scanIoRequest`, which honors scan-list membership.
214    fn io_intr_scan_independent(&self) -> bool {
215        false
216    }
217
218    /// Arm an output driver-callback (`asyn:READBACK`) cycle.
219    ///
220    /// Called by [`crate::server::database::PvDatabase::process_record_readback`]
221    /// immediately before the processing pass, mirroring C
222    /// `devAsynInt32.c::outputCallbackCallback` setting
223    /// `newOutputCallbackValue = 1` before `dbProcess`. Pair with
224    /// [`Self::reconcile_readback_callback`]: if the pass never reaches the
225    /// device read stage (the PACT entry guard bails because a put / FLNK
226    /// cycle still owns the record), the armed flag survives and reconcile
227    /// discards the stale callback-ring entry so a callback ring never
228    /// desyncs from the record's pop count. Default no-op — only output
229    /// callback-driven device support (asyn readback) needs it.
230    fn arm_readback_callback(&mut self) {}
231
232    /// Reconcile an armed output driver-callback cycle after processing.
233    ///
234    /// C `outputCallbackCallback` fallback (devEpics `devAsynInt32.c`): after
235    /// `dbProcess`, if `newOutputCallbackValue` is still set the record did
236    /// not process, so `getCallbackValue` is called to drop the stale ring
237    /// entry. Default no-op; see [`Self::arm_readback_callback`].
238    fn reconcile_readback_callback(&mut self) {}
239
240    /// Whether a driver-callback (`asyn:READBACK`) processing cycle replaces
241    /// this device's output stage with a value readback.
242    ///
243    /// C devEpics (`devAsynInt32.c::processAo`/`processBo`/…) takes the
244    /// `newOutputCallbackValue` readback branch on a callback cycle and never
245    /// calls the output `write()` — re-writing would re-assert the setpoint
246    /// and re-trigger the driver (the AD `Acquire` loop). Only device support
247    /// implementing that contract (the [`Self::arm_readback_callback`] /
248    /// [`Self::reconcile_readback_callback`] pair) returns `true`.
249    ///
250    /// Default `false`: a C `dbProcess` driven by a driver callback is a full
251    /// record process, output stage included. `devMotorAsyn` has no readback
252    /// suppression — the motor record dispatches its retry, backlash-leg,
253    /// NTM-stop, and queued-motion-resume commands from exactly these
254    /// CALLBACK_DATA passes, and suppressing the write strands them in the
255    /// command mailbox (DMOV stuck 0, MIP=RETRY|MOVE, later puts time out).
256    fn output_callback_readback(&self) -> bool {
257        false
258    }
259
260    /// Begin an asynchronous write (submit only, no blocking).
261    /// Returns `Some(handle)` if the write was submitted to a worker queue —
262    /// the caller should wait on the handle outside any record lock.
263    /// Returns `None` to fall back to synchronous [`write()`](DeviceSupport::write).
264    fn write_begin(
265        &mut self,
266        _record: &mut dyn Record,
267    ) -> CaResult<Option<Box<dyn WriteCompletion>>> {
268        Ok(None)
269    }
270
271    /// Handle a named command from the record's process() via
272    /// `ProcessAction::DeviceCommand`. This allows records to request
273    /// driver operations (e.g., scaler reset/arm/write_preset) without
274    /// holding a direct driver reference.
275    ///
276    /// `handle_command` runs AFTER the process snapshot has already been
277    /// built and notified, so any record field it mutates would not be
278    /// diffed by the snapshot path. The returned `Vec` names the record
279    /// fields the command changed; the framework posts a `DBE_VALUE`
280    /// monitor event for each, mirroring the explicit `db_post_events`
281    /// calls a C record makes from inside `process()` (e.g.
282    /// `scalerRecord.c:425-430` posts PR1/TP/FREQ after the driver
283    /// write-back). Return an empty `Vec` when no record field changed.
284    ///
285    /// Default: ignore, no fields changed.
286    fn handle_command(
287        &mut self,
288        _record: &mut dyn Record,
289        _command: &str,
290        _args: &[crate::types::EpicsValue],
291    ) -> CaResult<Vec<&'static str>> {
292        Ok(Vec::new())
293    }
294}
295
296/// Canonical device-support init sequence — the single owner of the
297/// "attach device support to a record" contract.
298///
299/// Both build paths (`crate::server::ioc_app::wire_device_support`
300/// and [`crate::server::ioc_builder::IocBuilder::build`]) MUST call
301/// this so a driver author can write one correct `init()`.
302///
303/// Order (C parity — `recGblInitConstantLink`-style field setup runs
304/// before `init_record`; `set_record_info` / `apply_record_info` are
305/// Rust extensions that supply that field context and therefore
306/// precede `init`):
307///
308/// 1. `set_record_info(name, scan)` — give the driver its record
309///    identity and scan mode.
310/// 2. `apply_record_info(info)` — forward `info(...)` tags so a
311///    driver that reads them inside `init()` sees a populated map.
312/// 3. `init(record)` — driver `init_record` equivalent.
313///
314/// On `init()` failure the record is flagged `INVALID` severity with
315/// a `SOFT` status and a diagnostic is logged — matching C
316/// `initDevSup`/`init_record` failure handling (the record is marked,
317/// not silently attached as healthy). On success, UDF is cleared if
318/// the driver produced a value.
319///
320/// The device is attached (`instance.device = Some(dev)`) regardless
321/// of init outcome so the record is addressable; a failed init leaves
322/// the alarm set.
323pub fn wire_device_to_record(instance: &mut RecordInstance, mut dev: Box<dyn DeviceSupport>) {
324    let name = instance.name.clone();
325    dev.set_record_info(&name, instance.common.scan);
326    dev.apply_record_info(&instance.info);
327    match dev.init(&mut *instance.record) {
328        Ok(()) => {
329            // Clear UDF if init successfully produced a value
330            // (e.g. an initial readback).
331            if instance.record.val().is_some() {
332                instance.common.udf = 0;
333            }
334        }
335        Err(e) => {
336            eprintln!(
337                "device support init failed for record '{name}' (DTYP '{}'): {e}",
338                instance.common.dtyp
339            );
340            // Flag the record so the failure is observable rather
341            // than presenting a healthy-looking record.
342            instance.common.sevr = AlarmSeverity::Invalid;
343            instance.common.stat = crate::server::recgbl::alarm_status::SOFT_ALARM;
344        }
345    }
346    instance.device = Some(dev);
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::error::CaError;
353    use crate::server::record::{AlarmSeverity, Record, RecordInstance, ScanType};
354    use crate::server::records::ai::AiRecord;
355    use std::collections::HashMap;
356    use std::sync::{Arc, Mutex};
357
358    /// Observed wiring state, shared with the test via `Arc` so it is
359    /// inspectable after the device is moved into the record.
360    #[derive(Default)]
361    struct WireObservation {
362        /// Info keys visible to `init()`.
363        info_at_init: Vec<String>,
364        /// Whether `set_record_info` ran before `init()`.
365        record_info_before_init: bool,
366        /// Whether `set_record_info` had run by the time `init` ran.
367        init_ran: bool,
368    }
369
370    /// Device support that records the wiring order and fails `init`.
371    struct ProbeDev {
372        obs: Arc<Mutex<WireObservation>>,
373        info: HashMap<String, String>,
374        record_info_set: bool,
375        fail_init: bool,
376    }
377    impl DeviceSupport for ProbeDev {
378        fn dtyp(&self) -> &str {
379            "ProbeDev"
380        }
381        fn write(&mut self, _record: &mut dyn Record) -> CaResult<()> {
382            Ok(())
383        }
384        fn set_record_info(&mut self, _name: &str, _scan: ScanType) {
385            self.record_info_set = true;
386        }
387        fn apply_record_info(&mut self, info: &HashMap<String, String>) {
388            self.info = info.clone();
389        }
390        fn init(&mut self, _record: &mut dyn Record) -> CaResult<()> {
391            let mut o = self.obs.lock().unwrap();
392            o.init_ran = true;
393            o.record_info_before_init = self.record_info_set;
394            o.info_at_init = self.info.keys().cloned().collect();
395            if self.fail_init {
396                Err(CaError::InvalidValue("device init failed".into()))
397            } else {
398                Ok(())
399            }
400        }
401    }
402
403    /// M2 regression: a device support whose `init()` returns `Err`
404    /// must NOT be attached as a healthy record — the record is
405    /// flagged INVALID severity with a SOFT status. (Pre-fix the
406    /// IocBuilder path discarded the error with `let _ =`.)
407    #[test]
408    fn wire_device_init_failure_flags_record_invalid() {
409        let mut instance = RecordInstance::new("TEST:AI".to_string(), AiRecord::new(0.0));
410        instance.common.dtyp = "ProbeDev".to_string();
411        let obs = Arc::new(Mutex::new(WireObservation::default()));
412        let dev = Box::new(ProbeDev {
413            obs: obs.clone(),
414            info: HashMap::new(),
415            record_info_set: false,
416            fail_init: true,
417        });
418
419        wire_device_to_record(&mut instance, dev);
420
421        assert_eq!(
422            instance.common.sevr,
423            AlarmSeverity::Invalid,
424            "failed device init must flag the record INVALID"
425        );
426        assert_eq!(
427            instance.common.stat,
428            crate::server::recgbl::alarm_status::SOFT_ALARM,
429        );
430        assert!(
431            instance.device.is_some(),
432            "device is still attached so the record is addressable"
433        );
434    }
435
436    /// M1 regression: the canonical wiring order is
437    /// set_record_info → apply_record_info → init. A driver reading
438    /// `info(...)` tags inside `init()` must see a populated map, and
439    /// `set_record_info` must have run first.
440    #[test]
441    fn wire_device_applies_info_and_record_info_before_init() {
442        let mut instance = RecordInstance::new("TEST:AI2".to_string(), AiRecord::new(0.0));
443        instance.common.dtyp = "ProbeDev".to_string();
444        instance.set_info("asyn:READBACK", "1");
445        let obs = Arc::new(Mutex::new(WireObservation::default()));
446        let dev = Box::new(ProbeDev {
447            obs: obs.clone(),
448            info: HashMap::new(),
449            record_info_set: false,
450            fail_init: false,
451        });
452
453        wire_device_to_record(&mut instance, dev);
454
455        let o = obs.lock().unwrap();
456        assert!(o.init_ran, "init must have run");
457        assert!(
458            o.record_info_before_init,
459            "set_record_info must run before init"
460        );
461        assert!(
462            o.info_at_init.iter().any(|k| k == "asyn:READBACK"),
463            "info(...) tags must be visible inside init()"
464        );
465    }
466}