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