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}