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