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