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