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 /// Return a receiver of out-of-band PROPERTY-class field posts.
166 ///
167 /// C parity: `registerInterruptUser(callbackEnum)` (devAsynInt32.c:319)
168 /// plus the per-record enum callback
169 /// (`interruptCallbackEnumMbbi`/`…Bi`, devAsynInt32.c:711-762), which
170 /// calls `setEnums` to re-key the record's state strings/values/
171 /// severities and then `db_post_events(precord, &precord->val,
172 /// DBE_PROPERTY)` so CA/PVA clients re-read the enum choices. This is
173 /// driven by the driver's `doCallbacksEnum`, independent of the
174 /// record's `SCAN` (it is not a value scan, so it does not process the
175 /// record).
176 ///
177 /// Each delivered message is the full `(field, value)` set to
178 /// write-and-post (the C `setEnums` field block). The framework drains
179 /// this receiver and calls
180 /// [`crate::server::database::PvDatabase::post_property_fields`].
181 /// Mirrors [`io_intr_receiver`](Self::io_intr_receiver): the device owns
182 /// the source subscription, the framework owns the post. Default:
183 /// `None` (device drives no property posts).
184 fn property_post_receiver(
185 &mut self,
186 ) -> Option<crate::runtime::sync::mpsc::Receiver<Vec<(String, crate::types::EpicsValue)>>> {
187 None
188 }
189
190 /// Whether this device drives record processing from its own callback
191 /// channel independently of the runtime `SCAN` menu.
192 ///
193 /// C parity: a `motorRecord` device callback (`statusCallback`) does its
194 /// own `dbScanLock` + `dbProcess` on every poll readback regardless of
195 /// `SCAN`, and the record stays `SCAN="Passive"` so a `dbPutField` to a
196 /// `pp(TRUE)` field (VAL/DVAL/...) still re-processes it
197 /// (`dbAccess.c:1263-1268`). asyn readback records behave the same way
198 /// (upstream PRs #60/#208 — output records follow driver-side changes
199 /// regardless of `SCAN`).
200 ///
201 /// When `true`, the I/O Intr wiring processes the record on every pulse
202 /// even when `SCAN != "I/O Intr"`. When `false` (default), processing is
203 /// gated on the record's current `SCAN` being `"I/O Intr"`, matching C
204 /// `scanIoRequest`, which honors scan-list membership.
205 fn io_intr_scan_independent(&self) -> bool {
206 false
207 }
208
209 /// Arm an output driver-callback (`asyn:READBACK`) cycle.
210 ///
211 /// Called by [`crate::server::database::PvDatabase::process_record_readback`]
212 /// immediately before the processing pass, mirroring C
213 /// `devAsynInt32.c::outputCallbackCallback` setting
214 /// `newOutputCallbackValue = 1` before `dbProcess`. Pair with
215 /// [`Self::reconcile_readback_callback`]: if the pass never reaches the
216 /// device read stage (the PACT entry guard bails because a put / FLNK
217 /// cycle still owns the record), the armed flag survives and reconcile
218 /// discards the stale callback-ring entry so a callback ring never
219 /// desyncs from the record's pop count. Default no-op — only output
220 /// callback-driven device support (asyn readback) needs it.
221 fn arm_readback_callback(&mut self) {}
222
223 /// Reconcile an armed output driver-callback cycle after processing.
224 ///
225 /// C `outputCallbackCallback` fallback (devEpics `devAsynInt32.c`): after
226 /// `dbProcess`, if `newOutputCallbackValue` is still set the record did
227 /// not process, so `getCallbackValue` is called to drop the stale ring
228 /// entry. Default no-op; see [`Self::arm_readback_callback`].
229 fn reconcile_readback_callback(&mut self) {}
230
231 /// Begin an asynchronous write (submit only, no blocking).
232 /// Returns `Some(handle)` if the write was submitted to a worker queue —
233 /// the caller should wait on the handle outside any record lock.
234 /// Returns `None` to fall back to synchronous [`write()`](DeviceSupport::write).
235 fn write_begin(
236 &mut self,
237 _record: &mut dyn Record,
238 ) -> CaResult<Option<Box<dyn WriteCompletion>>> {
239 Ok(None)
240 }
241
242 /// Handle a named command from the record's process() via
243 /// `ProcessAction::DeviceCommand`. This allows records to request
244 /// driver operations (e.g., scaler reset/arm/write_preset) without
245 /// holding a direct driver reference.
246 ///
247 /// `handle_command` runs AFTER the process snapshot has already been
248 /// built and notified, so any record field it mutates would not be
249 /// diffed by the snapshot path. The returned `Vec` names the record
250 /// fields the command changed; the framework posts a `DBE_VALUE`
251 /// monitor event for each, mirroring the explicit `db_post_events`
252 /// calls a C record makes from inside `process()` (e.g.
253 /// `scalerRecord.c:425-430` posts PR1/TP/FREQ after the driver
254 /// write-back). Return an empty `Vec` when no record field changed.
255 ///
256 /// Default: ignore, no fields changed.
257 fn handle_command(
258 &mut self,
259 _record: &mut dyn Record,
260 _command: &str,
261 _args: &[crate::types::EpicsValue],
262 ) -> CaResult<Vec<&'static str>> {
263 Ok(Vec::new())
264 }
265}
266
267/// Canonical device-support init sequence — the single owner of the
268/// "attach device support to a record" contract.
269///
270/// Both build paths ([`crate::server::ioc_app::wire_device_support`]
271/// and [`crate::server::ioc_builder::IocBuilder::build`]) MUST call
272/// this so a driver author can write one correct `init()`.
273///
274/// Order (C parity — `recGblInitConstantLink`-style field setup runs
275/// before `init_record`; `set_record_info` / `apply_record_info` are
276/// Rust extensions that supply that field context and therefore
277/// precede `init`):
278///
279/// 1. `set_record_info(name, scan)` — give the driver its record
280/// identity and scan mode.
281/// 2. `apply_record_info(info)` — forward `info(...)` tags so a
282/// driver that reads them inside `init()` sees a populated map.
283/// 3. `init(record)` — driver `init_record` equivalent.
284///
285/// On `init()` failure the record is flagged `INVALID` severity with
286/// a `SOFT` status and a diagnostic is logged — matching C
287/// `initDevSup`/`init_record` failure handling (the record is marked,
288/// not silently attached as healthy). On success, UDF is cleared if
289/// the driver produced a value.
290///
291/// The device is attached (`instance.device = Some(dev)`) regardless
292/// of init outcome so the record is addressable; a failed init leaves
293/// the alarm set.
294pub fn wire_device_to_record(instance: &mut RecordInstance, mut dev: Box<dyn DeviceSupport>) {
295 let name = instance.name.clone();
296 dev.set_record_info(&name, instance.common.scan);
297 dev.apply_record_info(&instance.info);
298 match dev.init(&mut *instance.record) {
299 Ok(()) => {
300 // Clear UDF if init successfully produced a value
301 // (e.g. an initial readback).
302 if instance.record.val().is_some() {
303 instance.common.udf = false;
304 }
305 }
306 Err(e) => {
307 eprintln!(
308 "device support init failed for record '{name}' (DTYP '{}'): {e}",
309 instance.common.dtyp
310 );
311 // Flag the record so the failure is observable rather
312 // than presenting a healthy-looking record.
313 instance.common.sevr = AlarmSeverity::Invalid;
314 instance.common.stat = crate::server::recgbl::alarm_status::SOFT_ALARM;
315 }
316 }
317 instance.device = Some(dev);
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use crate::error::CaError;
324 use crate::server::record::{AlarmSeverity, Record, RecordInstance, ScanType};
325 use crate::server::records::ai::AiRecord;
326 use std::collections::HashMap;
327 use std::sync::{Arc, Mutex};
328
329 /// Observed wiring state, shared with the test via `Arc` so it is
330 /// inspectable after the device is moved into the record.
331 #[derive(Default)]
332 struct WireObservation {
333 /// Info keys visible to `init()`.
334 info_at_init: Vec<String>,
335 /// Whether `set_record_info` ran before `init()`.
336 record_info_before_init: bool,
337 /// Whether `set_record_info` had run by the time `init` ran.
338 init_ran: bool,
339 }
340
341 /// Device support that records the wiring order and fails `init`.
342 struct ProbeDev {
343 obs: Arc<Mutex<WireObservation>>,
344 info: HashMap<String, String>,
345 record_info_set: bool,
346 fail_init: bool,
347 }
348 impl DeviceSupport for ProbeDev {
349 fn dtyp(&self) -> &str {
350 "ProbeDev"
351 }
352 fn write(&mut self, _record: &mut dyn Record) -> CaResult<()> {
353 Ok(())
354 }
355 fn set_record_info(&mut self, _name: &str, _scan: ScanType) {
356 self.record_info_set = true;
357 }
358 fn apply_record_info(&mut self, info: &HashMap<String, String>) {
359 self.info = info.clone();
360 }
361 fn init(&mut self, _record: &mut dyn Record) -> CaResult<()> {
362 let mut o = self.obs.lock().unwrap();
363 o.init_ran = true;
364 o.record_info_before_init = self.record_info_set;
365 o.info_at_init = self.info.keys().cloned().collect();
366 if self.fail_init {
367 Err(CaError::InvalidValue("device init failed".into()))
368 } else {
369 Ok(())
370 }
371 }
372 }
373
374 /// M2 regression: a device support whose `init()` returns `Err`
375 /// must NOT be attached as a healthy record — the record is
376 /// flagged INVALID severity with a SOFT status. (Pre-fix the
377 /// IocBuilder path discarded the error with `let _ =`.)
378 #[test]
379 fn wire_device_init_failure_flags_record_invalid() {
380 let mut instance = RecordInstance::new("TEST:AI".to_string(), AiRecord::new(0.0));
381 instance.common.dtyp = "ProbeDev".to_string();
382 let obs = Arc::new(Mutex::new(WireObservation::default()));
383 let dev = Box::new(ProbeDev {
384 obs: obs.clone(),
385 info: HashMap::new(),
386 record_info_set: false,
387 fail_init: true,
388 });
389
390 wire_device_to_record(&mut instance, dev);
391
392 assert_eq!(
393 instance.common.sevr,
394 AlarmSeverity::Invalid,
395 "failed device init must flag the record INVALID"
396 );
397 assert_eq!(
398 instance.common.stat,
399 crate::server::recgbl::alarm_status::SOFT_ALARM,
400 );
401 assert!(
402 instance.device.is_some(),
403 "device is still attached so the record is addressable"
404 );
405 }
406
407 /// M1 regression: the canonical wiring order is
408 /// set_record_info → apply_record_info → init. A driver reading
409 /// `info(...)` tags inside `init()` must see a populated map, and
410 /// `set_record_info` must have run first.
411 #[test]
412 fn wire_device_applies_info_and_record_info_before_init() {
413 let mut instance = RecordInstance::new("TEST:AI2".to_string(), AiRecord::new(0.0));
414 instance.common.dtyp = "ProbeDev".to_string();
415 instance.set_info("asyn:READBACK", "1");
416 let obs = Arc::new(Mutex::new(WireObservation::default()));
417 let dev = Box::new(ProbeDev {
418 obs: obs.clone(),
419 info: HashMap::new(),
420 record_info_set: false,
421 fail_init: false,
422 });
423
424 wire_device_to_record(&mut instance, dev);
425
426 let o = obs.lock().unwrap();
427 assert!(o.init_ran, "init must have run");
428 assert!(
429 o.record_info_before_init,
430 "set_record_info must run before init"
431 );
432 assert!(
433 o.info_at_init.iter().any(|k| k == "asyn:READBACK"),
434 "info(...) tags must be visible inside init()"
435 );
436 }
437}