Skip to main content

asyn_rs/
port.rs

1//! Port driver base and trait.
2//!
3//! # I/O Model
4//!
5//! Ports are driven by a `PortActor` running on a dedicated thread.
6//! The actor exclusively owns the driver and processes requests from a channel.
7//!
8//! **Cache path** (default `read_*`/`write_*` methods):
9//! - Default implementations operate on the parameter cache (non-blocking).
10//! - Background tasks update cache via `set_*_param()` + `call_param_callbacks()`.
11//!
12//! **Actor path** (requests submitted via [`crate::port_handle::PortHandle`]):
13//! - Each port gets a dedicated actor thread that dispatches requests to driver methods.
14//! - `can_block` indicates the port may perform blocking I/O.
15
16use std::collections::HashMap;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
19use std::time::{Duration, Instant, SystemTime};
20
21use std::any::Any;
22
23/// C `autoConnectDevice` reconnect throttle window (asynManager.c:713).
24/// A disconnected `auto_connect` device is refused a fresh connect attempt
25/// until this much time has elapsed since its last connect/disconnect
26/// transition or attempt, bounding reconnect storms to one attempt per
27/// window.
28const AUTO_CONNECT_THROTTLE: Duration = Duration::from_secs(2);
29
30/// First autonomous connect-retry delay after a port drops. C
31/// `exceptionDisconnect` arms the port's connect timer at `.01` seconds
32/// (asynManager.c:2181-2182), so the reconnect is attempted essentially
33/// immediately and then backs off to [`DEFAULT_SECONDS_BETWEEN_PORT_CONNECT`].
34const CONNECT_RETRY_INITIAL: Duration = Duration::from_millis(10);
35
36/// C `DEFAULT_SECONDS_BETWEEN_PORT_CONNECT` (asynManager.c:48) — the interval
37/// `portConnectProcessCallback` re-arms the connect timer at after a failed
38/// attempt (asynManager.c:3281).
39const DEFAULT_SECONDS_BETWEEN_PORT_CONNECT: Duration = Duration::from_secs(20);
40
41/// Per-address device state for multi-device ports.
42#[derive(Debug, Clone)]
43pub struct DeviceState {
44    pub connected: bool,
45    pub enabled: bool,
46    pub auto_connect: bool,
47    /// Monotonic instant of the last connect/disconnect transition or
48    /// auto-connect attempt for this device — the anchor for the 2s
49    /// reconnect throttle (C `dpCommon.lastConnectDisconnect`). `None`
50    /// mirrors C's zero-initialised timestamp: the first attempt is
51    /// always permitted.
52    pub last_connect_disconnect: Option<Instant>,
53}
54
55impl Default for DeviceState {
56    fn default() -> Self {
57        Self {
58            connected: true,
59            enabled: true,
60            auto_connect: true,
61            last_connect_disconnect: None,
62        }
63    }
64}
65
66/// One device's end-of-string terminators — C's `eosPvt.eosIn` / `eosPvt.eosOut`
67/// (asynInterposeEos.c:44-52), which exist once per (port, addr).
68#[derive(Debug, Clone, Default)]
69pub struct DeviceEos {
70    /// Input EOS sequence (max 2 bytes). Empty = no input EOS detection.
71    pub input: Vec<u8>,
72    /// Output EOS sequence (max 2 bytes). Empty = no output EOS append.
73    pub output: Vec<u8>,
74}
75
76/// The device an EOS hook's `asynUser` selects — the single owner of the rule,
77/// shared by [`PortDriverBase`] and the EOS interpose so the terminator a
78/// `setInputEos` writes is the one the next `read` on that user applies.
79///
80/// C creates the EOS interpose per (port, addr) and every hook takes the
81/// `asynUser` (asynInterposeEos.c:288-296), so on a multi-device port the addr
82/// picks the instance. On a port that never declared `ASYN_MULTIDEVICE` there
83/// are no devices to pick from: `findDpCommon` (asynManager.c:496-509) and
84/// `findInterface` resolve *every* addr to the port itself, so `asynSetEos`
85/// with addr 0 and with addr -1 must reach the same terminator. That collapse
86/// is what the `-1` key below is.
87pub fn eos_device_key(multi_device: bool, addr: i32) -> i32 {
88    if multi_device { addr } else { -1 }
89}
90
91use crate::error::{AsynError, AsynResult, AsynStatus};
92use crate::exception::{AsynException, ExceptionEvent, ExceptionManager};
93use crate::interfaces::InterfaceType;
94use crate::interpose::{
95    EomReason, OctetInterpose, OctetInterposeStack, OctetNext, OctetReadResult,
96};
97use crate::interrupt::{InterruptManager, InterruptValue, OctetFanOut};
98use crate::param::{EnumEntry, InterruptReason, ParamList, ParamType, ParamValue};
99use crate::trace::TraceManager;
100use crate::user::{AsynUser, ConnectCheck};
101
102/// C asyn `queueRequest` priority. In asyn-rs this exists as compatibility
103/// metadata only — there is no actual request queue or priority-based scheduling.
104/// Drivers manage their own async tasks directly.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
106pub enum QueuePriority {
107    Low = 0,
108    #[default]
109    Medium = 1,
110    High = 2,
111    /// Connect/disconnect operations — processed even when disabled/disconnected.
112    Connect = 3,
113}
114
115/// Port configuration flags.
116#[derive(Debug, Clone, Copy)]
117pub struct PortFlags {
118    /// True if port supports multiple sub-addresses (ASYN_MULTIDEVICE).
119    pub multi_device: bool,
120    /// True if port can block (ASYN_CANBLOCK).
121    ///
122    /// When `true`, the port gets a dedicated worker thread that serializes I/O via a
123    /// priority queue (matching C asyn's per-port thread model).
124    ///
125    /// When `false`, requests execute synchronously inline on the caller's thread
126    /// (no worker thread is spawned). This is appropriate for non-blocking drivers
127    /// whose `io_*` methods return immediately (e.g., cache-based parameter access).
128    pub can_block: bool,
129    /// True if port can be destroyed via shutdown_port (ASYN_DESTRUCTIBLE).
130    pub destructible: bool,
131}
132
133impl Default for PortFlags {
134    fn default() -> Self {
135        // `destructible: false` is the C asyn convention — see
136        // asynDriver.h:97 (`#define ASYN_DESTRUCTIBLE 0x0004`) — the
137        // attribute is opt-in via `pasynManager->registerPort(..., attr)`
138        // and `asynManager::shutdownPort` refuses to act on ports
139        // that did not opt in. Defaulting to `true` here over-applied
140        // shutdown rights to every driver that built PortFlags via
141        // `..PortFlags::default()`.
142        Self {
143            multi_device: false,
144            can_block: false,
145            destructible: false,
146        }
147    }
148}
149
150/// Base state shared by all port drivers.
151/// Contains the parameter library, interrupt manager, and connection state.
152///
153/// # Interpose concurrency
154///
155/// `interpose_octet` requires `&mut self` for all operations (both `push` and
156/// `dispatch_*`). Since `PortDriverBase` is always behind `Arc<Mutex<dyn PortDriver>>`,
157/// any access to `interpose_octet` requires the port lock. This naturally
158/// serializes interpose modifications with I/O dispatch — no additional
159/// synchronization is needed. **Callers must never modify the interpose stack
160/// without holding the port lock.**
161/// Where a port's `connected` truth lives.
162///
163/// `Own` — the port opens and closes its own link (every driver that dials out:
164/// IP, serial, USB-TMC, VXI-11, …), so its own cell is the truth.
165///
166/// `Shared` — the link belongs to another object and this port merely serves it.
167/// C models the case with a real child port whose `connectIt`/`closeConnection`
168/// the *owner* drives (`drvAsynIPServerPort.c:357-367` — the listener calls
169/// `pasynCommonSyncIO->connectDevice` on the child the moment it hands it a
170/// socket). Sharing the owner's cell is the same thing without the round trip,
171/// and it is what makes "the owner holds a live socket, the port says
172/// disconnected" unrepresentable rather than merely unlikely.
173#[derive(Debug, Clone)]
174enum Connection {
175    Own(bool),
176    Shared(Arc<AtomicBool>),
177}
178
179impl Connection {
180    fn get(&self) -> bool {
181        match self {
182            Connection::Own(c) => *c,
183            Connection::Shared(cell) => cell.load(Ordering::Acquire),
184        }
185    }
186}
187
188pub struct PortDriverBase {
189    pub port_name: String,
190    pub max_addr: usize,
191    pub flags: PortFlags,
192    pub params: ParamList,
193    pub interrupts: InterruptManager,
194    /// Whether the port's transport is up — read it with [`Self::is_connected`],
195    /// move it with [`Self::set_connected`].
196    ///
197    /// It is not a plain `bool` because not every port *owns* its link. An
198    /// IP-server child port serves a socket that lives in the parent's
199    /// [`crate::drivers::ip_server_port::ClientSlot`]: the listener assigns and
200    /// clears it, and the child cannot see either edge. A cached copy therefore
201    /// went stale in exactly the way that matters — the slot held a live client
202    /// while the child port still said `asynDisconnected` and refused every
203    /// read and write, forever (R13-50). Such a port shares the owner's cell
204    /// instead of copying it, so that state cannot be constructed.
205    connected: Connection,
206    /// The last value fanned out to listeners. Memory for the edge detector in
207    /// [`Self::sync_connection_edge`], never an answer to "is the port up?" —
208    /// [`Self::is_connected`] is the only thing that answers that, and it reads
209    /// the truth.
210    last_announced: bool,
211    pub enabled: bool,
212    pub auto_connect: bool,
213    /// C `dpCommon.defunct` (asynManager.c:2284) — the port was torn down via
214    /// `shutdownPort` and is gone for good. Read it with [`Self::is_defunct`].
215    ///
216    /// **Invariant: `defunct ⟹ !enabled`.** C establishes it in `shutdownPort`,
217    /// which clears `enabled` *and* sets `defunct` in the same breath (:2282-2283)
218    /// with the comment that disabling is what short-circuits `queueRequest` —
219    /// and indeed `queueRequest` has no defunct branch at all (:1539-1552): a
220    /// defunct port is refused as a *disabled* one, "port %s disabled". `defunct`
221    /// itself is only ever asked by `enable` (:2236, so the port cannot be
222    /// re-enabled) and by `findInterface` (:1487).
223    ///
224    /// The field is private so [`Self::shutdown_lifecycle`] is the only way to
225    /// set it, which is what makes the invariant hold by construction: nothing can
226    /// build a port that is defunct but still enabled, so no gate needs to ask
227    /// about defunct to refuse it (R15-50).
228    defunct: bool,
229    /// This port's announcement channel — C `dpCommon.exceptionUserList` plus
230    /// the `notifyPortThread` signal that `announceExceptionOccurred` ends with
231    /// (asynManager.c:611-637). Detachable ([`Self::exception_announcer`]) so a
232    /// worker thread the driver owns — the IP-server accept loop — announces
233    /// through the same counter and the same list as the actor does, instead of
234    /// reaching around them.
235    pub(crate) announcer: ExceptionAnnouncer,
236    pub options: HashMap<String, String>,
237    /// The EOS terminators, keyed per device the way C keys them: an `eosPvt`
238    /// is created per `asynInterposeEosConfig(portName, addr, ...)`
239    /// (asynInterposeEos.c:84-120), and every EOS hook takes the `asynUser`
240    /// that selects it (:288-296). Two devices on one multi-device port hold
241    /// two different terminators — a single port-wide pair could not.
242    ///
243    /// Keyed by [`eos_device_key`], so a port that never declared
244    /// `ASYN_MULTIDEVICE` collapses every addr onto one entry (C's
245    /// `findDpCommon`/`findInterface` resolve any addr to the port itself).
246    eos: HashMap<i32, DeviceEos>,
247    pub interpose_octet: OctetInterposeStack,
248    /// Trace configuration — C `dpCommon.trace` (asynManager.c:503). Same
249    /// owner as [`Self::announcer`]: bound by
250    /// [`crate::services::PortServices::bind`] at port creation.
251    pub(crate) trace: Option<Arc<TraceManager>>,
252    /// C `octetPvt.interruptProcess` — the last argument of
253    /// `pasynOctetBase->initialize` (asynOctetBase.c:161-169). When set, every
254    /// successful octet read fans the data out to the port's octet interrupt
255    /// users (`readIt` → `callInterruptUsers`, :224-238). The stream drivers set
256    /// it — drvAsynIPPort.c:1055, drvAsynSerialPort.c:1125,
257    /// drvAsynSerialPortWin32.c:798, drvAsynFTDIPort.cpp:616 — and it is what
258    /// makes a `stringin`/`waveform` with `SCAN="I/O Intr"` on such a port
259    /// process at all. Parameter-cache ports (echoDriver, USBTMC, GPIB,
260    /// IP-server) pass 0 and are unaffected.
261    pub octet_interrupt_process: bool,
262    /// Per-address device state for multi-device ports.
263    pub device_states: HashMap<i32, DeviceState>,
264    /// Timestamp source callback for custom timestamps.
265    pub timestamp_source: Option<Arc<dyn Fn() -> SystemTime + Send + Sync>>,
266    /// Port-level anchor for the 2s auto-reconnect throttle — the
267    /// monotonic instant of the last connect/disconnect transition or
268    /// auto-connect attempt (C `dpCommon.lastConnectDisconnect`). `None`
269    /// = no transition yet, so the first attempt is always permitted.
270    pub last_connect_disconnect: Option<Instant>,
271    /// Deadline for the next *autonomous* connect attempt — the Rust
272    /// equivalent of C's per-port `connectTimer` (`port.connectTimer`,
273    /// asynManager.c:223). `None` = disarmed.
274    ///
275    /// Armed by [`Self::set_connected`] on a disconnect (C
276    /// `exceptionDisconnect`, asynManager.c:2181-2182) and re-armed by the
277    /// actor after a failed attempt; cleared on connect. The actor is what
278    /// services it — see `PortActor::service_connect_timer` — so this field
279    /// is the whole handoff between the transition owner and the timer.
280    pub connect_retry_at: Option<Instant>,
281    /// Back-off between failed autonomous connect attempts. C
282    /// `port.secondsBetweenPortConnect`, initialised to
283    /// `DEFAULT_SECONDS_BETWEEN_PORT_CONNECT` = 20 s (asynManager.c:48, 3249)
284    /// and used to re-arm the timer at asynManager.c:3281.
285    pub seconds_between_port_connect: Duration,
286    /// How many times this port's link has come up — C `dpCommon.numberConnects`
287    /// (asynManager.c:150), incremented by `exceptionConnect` (:2158) and printed
288    /// by `asynReport` (:1057-1060). Its owner is
289    /// [`Self::sync_connection_edge`], the same edge owner that raises the
290    /// exception, so a connect that was never published is never counted.
291    pub number_connects: u64,
292}
293
294/// The one way to announce a port exception — C `announceExceptionOccurred`
295/// (asynManager.c:611-637): fan the event out over the port's exception list and
296/// signal `notifyPortThread` (:635-636).
297///
298/// It is a detachable handle rather than a method on [`PortDriverBase`] because
299/// the announcement is not the actor thread's private business: a driver-owned
300/// worker (the IP-server accept loop) also transitions a device, and C's
301/// `connectionListener` thread announces the same way the port thread does. A
302/// clone of this handle is that capability; it carries the wake counter with it,
303/// so an off-thread announcement still wakes the actor
304/// ([`PortDriverBase::exceptions_announced`]).
305#[derive(Clone)]
306pub struct ExceptionAnnouncer {
307    port_name: String,
308    /// Set once by [`crate::services::PortServices::bind`] at port creation, so
309    /// every clone taken afterwards carries the IOC's exception list.
310    sink: Option<Arc<ExceptionManager>>,
311    /// Shared so a clone's announcement is visible to the actor's count.
312    announced: Arc<AtomicU64>,
313}
314
315impl ExceptionAnnouncer {
316    fn new(port_name: &str) -> Self {
317        Self {
318            port_name: port_name.to_string(),
319            sink: None,
320            announced: Arc::new(AtomicU64::new(0)),
321        }
322    }
323
324    /// Announce. A port with no sink still counts the announcement — C's
325    /// fan-out over an empty list still signals the port thread — so the count
326    /// moves before the sink is consulted.
327    pub fn announce(&self, exception: AsynException, addr: i32) {
328        self.announced.fetch_add(1, Ordering::Release);
329        if let Some(ref sink) = self.sink {
330            sink.announce(&ExceptionEvent {
331                port_name: self.port_name.clone(),
332                exception,
333                addr,
334            });
335        }
336    }
337
338    fn count(&self) -> u64 {
339        self.announced.load(Ordering::Acquire)
340    }
341}
342
343impl PortDriverBase {
344    pub fn new(port_name: &str, max_addr: usize, flags: PortFlags) -> Self {
345        Self {
346            port_name: port_name.to_string(),
347            max_addr: max_addr.max(1),
348            flags,
349            params: ParamList::new(max_addr, flags.multi_device),
350            interrupts: InterruptManager::new(256),
351            connected: Connection::Own(true),
352            last_announced: true,
353            enabled: true,
354            auto_connect: true,
355            defunct: false,
356            announcer: ExceptionAnnouncer::new(port_name),
357            options: HashMap::new(),
358            eos: HashMap::new(),
359            interpose_octet: OctetInterposeStack::new(flags.multi_device),
360            trace: None,
361            octet_interrupt_process: false,
362            device_states: HashMap::new(),
363            timestamp_source: None,
364            last_connect_disconnect: None,
365            connect_retry_at: None,
366            seconds_between_port_connect: DEFAULT_SECONDS_BETWEEN_PORT_CONNECT,
367            number_connects: 0,
368        }
369    }
370
371    /// The EOS entry the given `asynUser` addr selects — see [`eos_device_key`].
372    pub fn eos_key(&self, addr: i32) -> i32 {
373        eos_device_key(self.flags.multi_device, addr)
374    }
375
376    /// This device's input EOS. An addr that has never been configured has an
377    /// empty terminator, C's zero-initialised `eosPvt.eosInLen`.
378    pub fn input_eos(&self, addr: i32) -> &[u8] {
379        self.eos
380            .get(&self.eos_key(addr))
381            .map_or(&[][..], |e| &e.input)
382    }
383
384    /// This device's output EOS (see [`Self::input_eos`]).
385    pub fn output_eos(&self, addr: i32) -> &[u8] {
386        self.eos
387            .get(&self.eos_key(addr))
388            .map_or(&[][..], |e| &e.output)
389    }
390
391    /// The write owner for this device's terminators — the queryable cache the
392    /// EOS readback (`get_input_eos`, the binary-suppress save/restore) reads.
393    /// The forward to the interpose stack lives in the `PortDriver` hook, which
394    /// is the only caller.
395    fn eos_entry(&mut self, addr: i32) -> &mut DeviceEos {
396        let key = self.eos_key(addr);
397        self.eos.entry(key).or_default()
398    }
399
400    /// Announce an exception through the global exception manager (if injected).
401    ///
402    /// C `announceExceptionOccurred` (asynManager.c:611-637) ends by signalling
403    /// `notifyPortThread` on a CANBLOCK port (:635-636) — the announcement *is* a
404    /// port-thread wake, which is why `asynEnable(port,1)` on a down port ends in
405    /// a connect attempt. [`Self::exceptions_announced`] is how the actor sees
406    /// that signal, so the count moves here and nowhere else: a port with no
407    /// exception sink still announced (C's fan-out over an empty list still
408    /// signals), so the count is bumped before the sink is even consulted.
409    pub fn announce_exception(&self, exception: AsynException, addr: i32) {
410        self.announcer.announce(exception, addr);
411    }
412
413    /// A clone of this port's announcement capability, for a worker thread the
414    /// driver owns. See [`ExceptionAnnouncer`].
415    pub fn exception_announcer(&self) -> ExceptionAnnouncer {
416        self.announcer.clone()
417    }
418
419    /// Bind the IOC's exception list. [`crate::services::PortServices::bind`] is
420    /// the production caller; tests that drive a driver without a runtime use it
421    /// to stand in for that binding.
422    pub(crate) fn bind_exception_sink(&mut self, sink: Arc<ExceptionManager>) {
423        self.announcer.sink = Some(sink);
424    }
425
426    /// How many exceptions this port has announced. Monotonic; the actor compares
427    /// it against the value it last saw to decide whether C would have signalled
428    /// `notifyPortThread` (asynManager.c:635-636).
429    pub fn exceptions_announced(&self) -> u64 {
430        self.announcer.count()
431    }
432
433    /// How many callbacks are registered on this port's exception list — C
434    /// `asynReport`'s `exceptionUsers` count (asynManager.c:1063).
435    pub fn exception_callback_count(&self) -> usize {
436        self.announcer
437            .sink
438            .as_ref()
439            .map_or(0, |m| m.callback_count())
440    }
441
442    /// Query whether the port is connected — the truth, wherever it lives.
443    pub fn is_connected(&self) -> bool {
444        self.connected.get()
445    }
446
447    /// The port's initial connection state, set while it is being constructed and
448    /// before it can have a listener. Not a transition: no exception fan-out, no
449    /// retry timer, no `lastConnectDisconnect` stamp. Every *transition* after
450    /// construction goes through [`Self::set_connected`].
451    ///
452    /// A port whose link is owned elsewhere has no initial state of its own to
453    /// set — the owner's cell already holds it — so this is a no-op there rather
454    /// than a silent overwrite of the owner's truth.
455    pub fn init_connected(&mut self, connected: bool) {
456        if let Connection::Own(c) = &mut self.connected {
457            *c = connected;
458            self.last_announced = connected;
459        }
460    }
461
462    /// Bind this port's connection to a cell owned by another object, making that
463    /// cell the port's truth from now on — see [`Connection::Shared`]. Called at
464    /// construction by a port that serves someone else's link (the IP-server
465    /// child port and its `ClientSlot`).
466    pub(crate) fn share_connection(&mut self, cell: Arc<AtomicBool>) {
467        self.last_announced = cell.load(Ordering::Acquire);
468        self.connected = Connection::Shared(cell);
469    }
470
471    /// C `asynPrint(pasynUserSelf, mask, ...)` — the port's own diagnostic
472    /// channel, gated by this port's trace mask and routed to this port's
473    /// trace file.
474    ///
475    /// Public because the drivers that need it are not all in this crate. C's
476    /// drivers are separate modules too (`drvModbusAsyn.cpp`,
477    /// `drvAsynIPPort.c`) and every one of them reports a fault it cannot
478    /// return — a poller has no caller to return to — by `asynPrint`ing it at
479    /// `ASYN_TRACE_ERROR`. With the [`TraceManager`] reachable only inside
480    /// this crate, an out-of-crate driver had no way to say anything at all,
481    /// and the failures it could not return became silent.
482    ///
483    /// A port whose services were never bound has no trace manager and the
484    /// call is a no-op; that is the same port that has no trace file to write
485    /// to and no mask to consult.
486    pub fn trace_print(&self, mask: crate::trace::TraceMask, msg: &str) {
487        if let Some(trace) = &self.trace {
488            trace.output(&self.port_name, mask, msg);
489        }
490    }
491
492    /// Single owner-API for the port-level `connected` transition.
493    ///
494    /// C parity: `exceptionConnect` (asynManager.c:2151-2160) and
495    /// `exceptionDisconnect` (:2174-2185) fire
496    /// `asynExceptionConnect` only when the state actually changes.
497    /// All driver code that toggles connection state MUST go through
498    /// this helper — the `connected` cell is private precisely so that a driver
499    /// cannot assign it and then hand-roll an `announce_exception(Connect, -1)`,
500    /// which bypasses the edge guard and fans spurious duplicates out to
501    /// listeners (CA gateway shadow tasks, asynRecord, monitor relays).
502    ///
503    /// On a port whose link is owned elsewhere (`Connection::Shared`) the write
504    /// is not this port's to make — the owner already moved the truth — so the
505    /// call reduces to publishing whatever edge that produced.
506    ///
507    /// Returns `true` if the state actually changed (a fan-out
508    /// happened); `false` if the call was a no-op.
509    pub fn set_connected(&mut self, connected: bool) -> bool {
510        if let Connection::Own(c) = &mut self.connected {
511            *c = connected;
512        }
513        self.sync_connection_edge()
514    }
515
516    /// Publish the port's connection edge if the truth has moved since the last
517    /// fan-out: the single owner of `exceptionConnect`/`exceptionDisconnect`
518    /// (asynManager.c:2151-2185), of the interpose stack's connection reset and of
519    /// the retry timer.
520    ///
521    /// [`Self::set_connected`] is one caller. The other is the actor, on a port
522    /// whose link is owned elsewhere: the owner (an IP-server listener assigning a
523    /// slot) moves the truth without this port's actor running, and C fans that
524    /// edge out from the owner's thread — `pasynCommonSyncIO->connectDevice` on
525    /// the child (drvAsynIPServerPort.c:357-367). Here it is published when the
526    /// child's actor next touches the port, which is the moment it can matter.
527    ///
528    /// Returns `true` if an edge was published.
529    pub fn sync_connection_edge(&mut self) -> bool {
530        let connected = self.connected.get();
531        if self.last_announced == connected {
532            return false;
533        }
534        self.last_announced = connected;
535        if !connected {
536            // C `exceptionDisconnect` stamps `lastConnectDisconnect` on
537            // every disconnect (asynManager.c:2184) so the auto-reconnect
538            // throttle measures from the moment the link dropped.
539            self.last_connect_disconnect = Some(Instant::now());
540            // ...and arms the port's connect timer at .01 s when the port is
541            // auto-connect (asynManager.c:2181-2182), which is what makes the
542            // reconnect *autonomous*: it does not wait for queued traffic.
543            if self.auto_connect {
544                self.connect_retry_at = Some(Instant::now() + CONNECT_RETRY_INITIAL);
545            }
546        } else {
547            // C `exceptionConnect` counts the connects it publishes
548            // (`++pdpCommon->numberConnects`, asynManager.c:2158) — the count
549            // `asynReport` prints, and the operator's only way to see a port that
550            // is flapping. It belongs to this owner because C increments it in the
551            // same function that raises the exception, so a connect that never
552            // fanned out is never counted.
553            self.number_connects += 1;
554            // The link is up — nothing left to retry. (C leaves the timer
555            // running and lets `portConnectTimerCallback` no-op on the
556            // `!connected` guard, asynManager.c:3257; disarming here is the
557            // same observable behaviour without the pointless wakeup.)
558            self.connect_retry_at = None;
559        }
560        // The interpose stack is a subscriber of this transition, exactly as
561        // in C: `asynInterposeEos` registers an exception callback
562        // (asynInterposeEos.c:110) and drops its read-ahead buffer +
563        // partial-EOS match on `asynExceptionConnect`
564        // (asynInterposeEos.c:142-151). Both C edges — `exceptionConnect`
565        // (asynManager.c:2158) and `exceptionDisconnect` (asynManager.c:2185)
566        // — raise that same exception, so both edges reset here. Driving the
567        // hook from this owner (rather than from an out-of-band subscriber)
568        // keeps it impossible to change `connected` without the stack
569        // hearing about it: `interpose_octet` and `connected` live in the
570        // same struct behind the same lock.
571        self.interpose_octet.connection_changed();
572        self.announce_exception(AsynException::Connect, -1);
573        true
574    }
575
576    /// Per-address variant — for multi-device ports. Same edge
577    /// guarantee as [`Self::set_connected`].
578    ///
579    /// Deliberately does *not* reset the interpose stack. In C each
580    /// interpose is installed on one (port, addr) pair and registers its
581    /// exception callback on that address's `dpCommon`, so a device-level
582    /// connect exception only resets *that* device's interpose
583    /// (asynManager.c:611-625 fans out per-`dpCommon`). `interpose_octet`
584    /// here is port-scoped, so clearing it from a per-device transition
585    /// would discard read-ahead belonging to the port's other addresses.
586    /// The port-level transition owner [`Self::set_connected`] carries the
587    /// reset.
588    pub fn set_addr_connected(&mut self, addr: i32, connected: bool) -> bool {
589        let was = self.device_state(addr).connected;
590        if was == connected {
591            return false;
592        }
593        self.device_state(addr).connected = connected;
594        if !connected {
595            // Per-device disconnect stamp — same throttle anchor as the
596            // port-level path (C `exceptionDisconnect`, asynManager.c:2184).
597            self.device_state(addr).last_connect_disconnect = Some(Instant::now());
598        }
599        self.announce_exception(AsynException::Connect, addr);
600        true
601    }
602
603    /// 2.0s auto-reconnect throttle gate — C `autoConnectDevice`
604    /// (asynManager.c:712-713, 729-730).
605    ///
606    /// Returns `true` when a fresh auto-connect attempt is permitted:
607    /// either no transition has been recorded yet (mirrors C's
608    /// zero-initialised `lastConnectDisconnect`, whose diff against `now`
609    /// is effectively infinite), or at least `AUTO_CONNECT_THROTTLE` has
610    /// elapsed since the last transition or attempt. A disconnected
611    /// `auto_connect` device that just dropped — or whose previous
612    /// reconnect just failed — is refused until the window passes, so a
613    /// burst of N queued requests triggers at most one full connect
614    /// attempt per window instead of N back-to-back attempts.
615    ///
616    /// Uses monotonic [`Instant`], not wall clock: the throttle is purely
617    /// internal timing, never serialised, so it must be immune to NTP
618    /// steps. `addr` selects the anchor via [`Self::is_device_addr`].
619    pub fn auto_connect_throttle_ok(&self, addr: i32, now: Instant) -> bool {
620        let last = if self.is_device_addr(addr) {
621            self.device_states
622                .get(&addr)
623                .and_then(|d| d.last_connect_disconnect)
624        } else {
625            self.last_connect_disconnect
626        };
627        match last {
628            None => true,
629            Some(t) => now.saturating_duration_since(t) >= AUTO_CONNECT_THROTTLE,
630        }
631    }
632
633    /// Does `addr` name a *device* on this port, or the port itself?
634    ///
635    /// C `findDpCommon` resolves a `pasynUser` to `&pdevice->dpc` only when
636    /// the port is multi-device AND the user is bound to a real address;
637    /// otherwise to `&pport->dpc` (a connectDevice with `addr < 0` leaves
638    /// `pdevice` null). This is the one owner of that resolution, so the
639    /// throttle read [`Self::auto_connect_throttle_ok`] and the throttle
640    /// write [`Self::stamp_auto_connect_attempt`] can never disagree about
641    /// which anchor an address refers to. Keying on the address (rather than
642    /// on `multi_device` alone) is what lets a multi-device port hold a
643    /// *port-level* anchor at `addr = -1`: the old form sent `-1` into
644    /// `device_states`, inventing a phantom device whose anchor no
645    /// disconnect ever stamped.
646    pub fn is_device_addr(&self, addr: i32) -> bool {
647        self.flags.multi_device && addr >= 0
648    }
649
650    /// Single owner for the post-attempt throttle stamp. C
651    /// `autoConnectDevice` stamps `lastConnectDisconnect` immediately after
652    /// every `connectAttempt`, success or failure (asynManager.c:718,
653    /// 735), so the window restarts from the end of the attempt — a failed
654    /// reconnect is not retried until the throttle elapses again.
655    pub fn stamp_auto_connect_attempt(&mut self, addr: i32, now: Instant) {
656        if self.is_device_addr(addr) {
657            self.device_state(addr).last_connect_disconnect = Some(now);
658        } else {
659            self.last_connect_disconnect = Some(now);
660        }
661    }
662
663    /// Query whether the port is enabled.
664    pub fn is_enabled(&self) -> bool {
665        self.enabled
666    }
667
668    /// Single owner-API for the port-level `enabled` transition.
669    ///
670    /// C `enable` (asynManager.c:2222-2249) refuses a shut-down port:
671    /// when `defunct` it returns `asynDisabled` *without* touching
672    /// `enabled` and *without* firing the `asynExceptionEnable` fan-out.
673    /// Otherwise it sets `enabled` and announces unconditionally (no
674    /// state-change guard). The actor's `SetEnable` op is a lifecycle op
675    /// that bypasses [`Self::check_ready`], so this guard is the only
676    /// thing that stops a defunct port from being re-enabled or fanning
677    /// out a spurious exception — it must live here, in the one owner of
678    /// the transition.
679    pub fn set_enabled(&mut self, enabled: bool) -> AsynResult<()> {
680        if self.defunct {
681            return Err(Self::shut_down_error());
682        }
683        self.enabled = enabled;
684        self.announce_exception(AsynException::Enable, -1);
685        Ok(())
686    }
687
688    /// Per-address variant — same defunct refusal as [`Self::set_enabled`].
689    /// `defunct` is modelled at the port level (a shut-down port takes its
690    /// devices with it), so a defunct port refuses per-device enable/disable
691    /// too, matching C's `dpCommon.defunct` check on the resolved device.
692    pub fn set_addr_enabled(&mut self, addr: i32, enabled: bool) -> AsynResult<()> {
693        if self.defunct {
694            return Err(Self::shut_down_error());
695        }
696        self.device_state(addr).enabled = enabled;
697        self.announce_exception(AsynException::Enable, addr);
698        Ok(())
699    }
700
701    /// Query whether auto-connect is enabled.
702    pub fn is_auto_connect(&self) -> bool {
703        self.auto_connect
704    }
705
706    /// Toggle the auto-connect flag at runtime.
707    ///
708    /// C parity: `autoConnectAsyn` (asynManager.c:2310-2324) always
709    /// fires `asynExceptionAutoConnect` regardless of prior state
710    /// (no state-change guard). Mirror that — every call announces.
711    /// Driver constructors that initialise `base.auto_connect`
712    /// directly during `PortDriver::new()` keep the silent path
713    /// (the port is not yet registered, so no listeners exist).
714    pub fn set_auto_connect(&mut self, yes: bool) {
715        self.auto_connect = yes;
716        // asyn PR #217 (asynManager.c:2322-2324): flipping auto-connect ON
717        // while the port is down starts the connect timer, so the flip
718        // alone brings the port up. Pre-fix, the only autonomous attempt
719        // was the one exception-wake pass — one try, then silence until
720        // traffic or a disconnect edge armed the timer.
721        if yes && !self.connected.get() {
722            self.connect_retry_at = Some(Instant::now() + CONNECT_RETRY_INITIAL);
723        }
724        self.announce_exception(AsynException::AutoConnect, -1);
725    }
726
727    /// Per-address variant — for multi-device ports. C parity:
728    /// `autoConnectAsyn` walks dpCommon via findDpCommon so a per-
729    /// device pasynUser hits the device's dpc, otherwise the port's
730    /// dpc (asynManager.c:2314 + findDpCommon).
731    pub fn set_auto_connect_addr(&mut self, addr: i32, yes: bool) {
732        let device_down = {
733            let dev = self.device_state(addr);
734            dev.auto_connect = yes;
735            !dev.connected
736        };
737        // Same PR #217 arm as `set_auto_connect`: C keys the check on the
738        // dpCommon `findDpCommon` resolved — the DEVICE's — while the
739        // timer it starts is the port's (asynManager.c:2322-2324).
740        if yes && device_down {
741            self.connect_retry_at = Some(Instant::now() + CONNECT_RETRY_INITIAL);
742        }
743        self.announce_exception(AsynException::AutoConnect, addr);
744    }
745
746    /// Query whether the port has been marked defunct via
747    /// [`Self::shutdown_lifecycle`] — once true the port is gone for
748    /// good, mirroring C asynManager.c:2266-2269.
749    pub fn is_defunct(&self) -> bool {
750        self.defunct
751    }
752
753    /// The one place a shut-down port is named in an error message, and the
754    /// only one C has: `enable` on a defunct device (asynManager.c:2236-2241).
755    /// Every other gate refuses a defunct port as a disabled one.
756    fn shut_down_error() -> AsynError {
757        AsynError::Status {
758            status: AsynStatus::Disabled,
759            message: "asynManager:enable: port has been shut down".to_string(),
760        }
761    }
762
763    /// C `queueRequest`'s gate (asynManager.c:1539-1552), and the single owner
764    /// of the two refusals it is built from.
765    ///
766    /// They are independent, and the [`ConnectCheck`] the caller hands in
767    /// selects between them — it can waive the *connected* refusal and nothing
768    /// else. There is no argument, op class or priority that waives
769    /// [`Self::check_enabled`]: C's `if(!pport->dpc.enabled) return asynDisabled`
770    /// (:1541-1546) sits *above* `checkPortConnect` and is reached by every
771    /// request, and its port thread refuses to run anything at all on a disabled
772    /// port (`portThread`, :802-805).
773    ///
774    /// The `ConnectCheck` can only come from [`AsynUser::connect_check`], so the
775    /// waiver is available exactly to the requests C gives it to.
776    ///
777    /// Its refusal is [`AsynError::QueueRefused`], not [`AsynError::Status`]:
778    /// C's refusal is `queueRequest`'s *return value*, so the callback never
779    /// runs and nothing it implies happened. A caller must be able to tell that
780    /// from a driver error raised *inside* a callback that did run — the record
781    /// writes the refusal to ERRS and stops, where a driver error still gets the
782    /// callback's readback and `monitorStatus` tail (asynRecord.c:571-576 vs
783    /// :788-900). This is the only place that stamps it.
784    pub fn check_queue(&self, addr: i32, connect: ConnectCheck) -> AsynResult<()> {
785        self.check_queue_inner(addr, connect)
786            .map_err(AsynError::into_queue_refusal)
787    }
788
789    /// The gate's body, before the refusal is stamped as a queue refusal.
790    ///
791    /// Three blocks, in C's order:
792    ///
793    /// 1. `!pport->dpc.enabled → asynDisabled` (:1541-1546) — port level,
794    ///    unconditional, reached by every request.
795    /// 2. `checkPortConnect && !pport->dpc.connected → asynDisconnected`
796    ///    (:1547-1552) — port level, waived by the request's own user. (C's
797    ///    `checkPortConnect == FALSE` reads *no* connected flag: the port thread
798    ///    drains the Connect queue before it ever calls `autoConnectDevice`,
799    ///    :812-856.)
800    /// 3. The **device** block (:1553-1575), which C runs only for a
801    ///    *synchronous* port: it sits bodily inside
802    ///    `if(!(pport->attributes & ASYN_CANBLOCK))` at :1553. A CANBLOCK port's
803    ///    device-level enabled/connected checks belong to the port THREAD
804    ///    (:874-884), which does something else entirely with them — a disabled
805    ///    device's request *waits in the queue* (`continue`), it is not refused
806    ///    — so applying them here refused requests C parks (R15-47). Every real
807    ///    transport port is CANBLOCK.
808    fn check_queue_inner(&self, addr: i32, connect: ConnectCheck) -> AsynResult<()> {
809        self.check_enabled()?;
810        if connect == ConnectCheck::Required {
811            self.check_port_connected()?;
812        }
813        if !self.flags.can_block {
814            // C :1561-1567 — the device-enabled refusal in the synchronous block
815            // is *not* conditioned on `checkPortConnect`; only the connected one
816            // below is (:1568).
817            self.check_device_enabled(addr)?;
818            if connect == ConnectCheck::Required {
819                self.check_device_connected(addr)?;
820            }
821        }
822        Ok(())
823    }
824
825    /// The unconditional half of the queue gate: a disabled port refuses every
826    /// request (asynManager.c:1541-1546).
827    ///
828    /// There is no defunct branch here, and there is none in C's `queueRequest`
829    /// either: `shutdownPort` clears `enabled` alongside setting `defunct`
830    /// (:2282-2283) precisely so this one check answers for both. The invariant
831    /// `defunct ⟹ !enabled` (see the field doc) is what makes that sound.
832    pub fn check_enabled(&self) -> AsynResult<()> {
833        if !self.enabled {
834            return Err(AsynError::Status {
835                status: AsynStatus::Disabled,
836                message: format!("port {} disabled", self.port_name),
837            });
838        }
839        Ok(())
840    }
841
842    /// The port-level connected refusal (asynManager.c:1547-1552).
843    pub fn check_port_connected(&self) -> AsynResult<()> {
844        if !self.is_connected() {
845            return Err(AsynError::Status {
846                status: AsynStatus::Disconnected,
847                message: format!("port {} not connected", self.port_name),
848            });
849        }
850        Ok(())
851    }
852
853    /// The device-level enabled refusal, C's text verbatim
854    /// (asynManager.c:1561-1567). Its *use* differs by port class, which is why
855    /// it is a check and not a gate: `queueRequest` returns it on a synchronous
856    /// port, while on a CANBLOCK port the same condition makes `portThread` park
857    /// the request instead (:875). Both callers ask this one function.
858    ///
859    /// A port that is not `ASYN_MULTIDEVICE`, or an address with no device state,
860    /// resolves to the port's own `dpCommon` in C's `findDpCommon` — already
861    /// checked above — so there is nothing device-level left to refuse.
862    pub fn check_device_enabled(&self, addr: i32) -> AsynResult<()> {
863        if let Some(ds) = self.device(addr) {
864            if !ds.enabled {
865                return Err(AsynError::Status {
866                    status: AsynStatus::Disabled,
867                    // C's double space is verbatim (asynManager.c:1564).
868                    message: format!("port {}  or device {} not enabled", self.port_name, addr),
869                });
870            }
871        }
872        Ok(())
873    }
874
875    /// The device-level connected refusal (asynManager.c:1568-1575). Same
876    /// split as [`Self::check_device_enabled`]: `queueRequest` returns it on a
877    /// synchronous port; on a CANBLOCK port `portThread` answers the same
878    /// condition with the request's timeout callback (:884-885).
879    pub fn check_device_connected(&self, addr: i32) -> AsynResult<()> {
880        if let Some(ds) = self.device(addr) {
881            if !ds.connected {
882                return Err(AsynError::Status {
883                    status: AsynStatus::Disconnected,
884                    message: format!("port {} or device {} not connected", self.port_name, addr),
885                });
886            }
887        }
888        Ok(())
889    }
890
891    /// The device `addr` resolves to, or `None` when C's `findDpCommon` would
892    /// resolve it to the port's own `dpCommon` (not multi-device, or no device
893    /// created at that address).
894    fn device(&self, addr: i32) -> Option<&DeviceState> {
895        if !self.flags.multi_device {
896            return None;
897        }
898        self.device_states.get(&addr)
899    }
900
901    /// Check that the port is enabled, connected, and not defunct.
902    /// Returns `Err(Disabled)`, `Err(Disconnected)`, or `Err(Disabled)`
903    /// (defunct => permanently disabled) otherwise.
904    pub fn check_ready(&self) -> AsynResult<()> {
905        self.check_enabled()?;
906        self.check_port_connected()
907    }
908
909    /// Run the C `shutdownPort` lifecycle (asynManager.c:2251-2308):
910    ///
911    /// 1. Refuse if the port did not opt into `ASYN_DESTRUCTIBLE`
912    ///    (returns `Err(Status::Error)`).
913    /// 2. Short-circuit if already defunct (idempotent — returns Ok).
914    /// 3. Set `enabled = false`, `defunct = true` — every subsequent
915    ///    request through [`Self::check_ready`] fails.
916    /// 4. Broadcast `AsynException::Shutdown` so registered observers
917    ///    (CA gateways, monitor sinks) tear down their handles.
918    ///
919    /// Drivers should call this from their own shutdown plumbing and
920    /// then release any hardware-owned resources via their
921    /// [`PortDriver::shutdown`] implementation. Callers from outside
922    /// the runtime can drive the same lifecycle via
923    /// [`crate::manager::PortManager::shutdown_port`].
924    pub fn shutdown_lifecycle(&mut self) -> AsynResult<()> {
925        if self.defunct {
926            // Idempotent — C asynManager.c:2266-2269 returns asynSuccess.
927            return Ok(());
928        }
929        if !self.flags.destructible {
930            return Err(AsynError::Status {
931                status: AsynStatus::Error,
932                message: format!(
933                    "port {} does not support shutting down (ASYN_DESTRUCTIBLE not set)",
934                    self.port_name
935                ),
936            });
937        }
938        self.enabled = false;
939        self.defunct = true;
940        self.announce_exception(AsynException::Shutdown, -1);
941        Ok(())
942    }
943
944    /// Check that port + device address are both ready — the whole of C's
945    /// synchronous-port gate (asynManager.c:1539-1575) in one call. Drivers that
946    /// re-check inside their own I/O use it; the queue gate reaches the same
947    /// checks through [`Self::check_queue`], which splits them by port class.
948    pub fn check_ready_addr(&self, addr: i32) -> AsynResult<()> {
949        self.check_ready()?;
950        self.check_device_enabled(addr)?;
951        self.check_device_connected(addr)
952    }
953
954    /// Get or create a device state for the given address.
955    ///
956    /// A device created here inherits the port's `auto_connect`, as C's
957    /// `locateDevice` does: `dpCommonInit(pport, pdevice, pport->dpc.autoConnect)`
958    /// (asynManager.c:584). Defaulting it to `true` on a manual-connect port
959    /// would make `asynReport`'s per-device `autoConnect Yes` a lie, and would
960    /// hand `autoConnectDevice` a device it may reconnect on a port whose
961    /// operator turned auto-connect off.
962    pub fn device_state(&mut self, addr: i32) -> &mut DeviceState {
963        let port_auto_connect = self.auto_connect;
964        self.device_states
965            .entry(addr)
966            .or_insert_with(|| DeviceState {
967                auto_connect: port_auto_connect,
968                ..DeviceState::default()
969            })
970    }
971
972    /// Check if a specific device address is connected.
973    pub fn is_device_connected(&self, addr: i32) -> bool {
974        self.device_states
975            .get(&addr)
976            .map_or(true, |ds| ds.connected)
977    }
978
979    /// Set a specific device address as connected.
980    ///
981    /// C parity: announce only on actual transition
982    /// (asynManager.c:2151-2160 — `exceptionConnect` rejects
983    /// already-connected; we keep an Ok return for idempotency but
984    /// suppress the duplicate fan-out so subscribers don't see
985    /// spurious connect events). Thin wrapper over
986    /// [`Self::set_addr_connected`] for callers that prefer the
987    /// directional verb.
988    pub fn connect_addr(&mut self, addr: i32) {
989        self.set_addr_connected(addr, true);
990    }
991
992    /// Set a specific device address as disconnected.
993    ///
994    /// C parity: announce only on actual transition
995    /// (asynManager.c:2174-2185). Thin wrapper over
996    /// [`Self::set_addr_connected`].
997    pub fn disconnect_addr(&mut self, addr: i32) {
998        self.set_addr_connected(addr, false);
999    }
1000
1001    /// Enable a specific device address. Convenience facade over the
1002    /// guarded owner [`Self::set_addr_enabled`]; a defunct port no-ops.
1003    pub fn enable_addr(&mut self, addr: i32) {
1004        let _ = self.set_addr_enabled(addr, true);
1005    }
1006
1007    /// Disable a specific device address. Convenience facade over the
1008    /// guarded owner [`Self::set_addr_enabled`]; a defunct port no-ops.
1009    pub fn disable_addr(&mut self, addr: i32) {
1010        let _ = self.set_addr_enabled(addr, false);
1011    }
1012
1013    /// Set a custom timestamp source callback.
1014    pub fn register_timestamp_source<F>(&mut self, source: F)
1015    where
1016        F: Fn() -> SystemTime + Send + Sync + 'static,
1017    {
1018        self.timestamp_source = Some(Arc::new(source));
1019    }
1020
1021    /// Drop a registered source — C `unregisterTimeStampSource`
1022    /// (asynManager.c:334), which restores `defaultTimeStampSource` (:332).
1023    pub fn unregister_timestamp_source(&mut self) {
1024        self.timestamp_source = None;
1025    }
1026
1027    /// Get current timestamp from the registered source, or SystemTime::now().
1028    pub fn current_timestamp(&self) -> SystemTime {
1029        self.timestamp_source
1030            .as_ref()
1031            .map_or_else(SystemTime::now, |f| f())
1032    }
1033
1034    pub fn create_param(&mut self, name: &str, param_type: ParamType) -> AsynResult<usize> {
1035        self.params.create_param(name, param_type)
1036    }
1037
1038    pub fn find_param(&self, name: &str) -> Option<usize> {
1039        self.params.find_param(name)
1040    }
1041
1042    // --- Convenience param accessors ---
1043
1044    pub fn set_int32_param(&mut self, index: usize, addr: i32, value: i32) -> AsynResult<()> {
1045        self.params.set_int32(index, addr, value)
1046    }
1047
1048    pub fn get_int32_param(&self, index: usize, addr: i32) -> AsynResult<i32> {
1049        self.params.get_int32(index, addr)
1050    }
1051
1052    /// Strict variant — returns [`AsynError::ParamUndefined`] when the
1053    /// cache entry has never been set (C parity for `asynParamUndefined`).
1054    /// See [`crate::param::ParamList::get_int32_strict`].
1055    pub fn get_int32_param_strict(&self, index: usize, addr: i32) -> AsynResult<i32> {
1056        self.params.get_int32_strict(index, addr)
1057    }
1058
1059    pub fn set_int64_param(&mut self, index: usize, addr: i32, value: i64) -> AsynResult<()> {
1060        self.params.set_int64(index, addr, value)
1061    }
1062
1063    pub fn get_int64_param(&self, index: usize, addr: i32) -> AsynResult<i64> {
1064        self.params.get_int64(index, addr)
1065    }
1066
1067    /// Strict variant — see [`crate::param::ParamList::get_int64_strict`].
1068    pub fn get_int64_param_strict(&self, index: usize, addr: i32) -> AsynResult<i64> {
1069        self.params.get_int64_strict(index, addr)
1070    }
1071
1072    pub fn set_float64_param(&mut self, index: usize, addr: i32, value: f64) -> AsynResult<()> {
1073        self.params.set_float64(index, addr, value)
1074    }
1075
1076    pub fn get_float64_param(&self, index: usize, addr: i32) -> AsynResult<f64> {
1077        self.params.get_float64(index, addr)
1078    }
1079
1080    /// Strict variant — see [`crate::param::ParamList::get_float64_strict`].
1081    pub fn get_float64_param_strict(&self, index: usize, addr: i32) -> AsynResult<f64> {
1082        self.params.get_float64_strict(index, addr)
1083    }
1084
1085    pub fn set_string_param(&mut self, index: usize, addr: i32, value: String) -> AsynResult<()> {
1086        self.params.set_string(index, addr, value)
1087    }
1088
1089    pub fn get_string_param(&self, index: usize, addr: i32) -> AsynResult<&str> {
1090        self.params.get_string(index, addr)
1091    }
1092
1093    /// Strict variant — see [`crate::param::ParamList::get_string_strict`].
1094    pub fn get_string_param_strict(&self, index: usize, addr: i32) -> AsynResult<&str> {
1095        self.params.get_string_strict(index, addr)
1096    }
1097
1098    /// Set a UInt32Digital parameter. `interrupt_mask` mirrors C
1099    /// `setUIntDigitalParam(.., interruptMask)` (asynPortDriver.cpp:1369,
1100    /// 1381): bits to force into the I/O Intr callback mask even when the
1101    /// stored value did not change. Pass `0` for a plain value set (the
1102    /// 3-arg C overload, asynPortDriver.cpp:1347).
1103    pub fn set_uint32_param(
1104        &mut self,
1105        index: usize,
1106        addr: i32,
1107        value: u32,
1108        mask: u32,
1109        interrupt_mask: u32,
1110    ) -> AsynResult<()> {
1111        self.params
1112            .set_uint32(index, addr, value, mask, interrupt_mask)
1113    }
1114
1115    pub fn get_uint32_param(&self, index: usize, addr: i32) -> AsynResult<u32> {
1116        self.params.get_uint32(index, addr)
1117    }
1118
1119    /// Strict variant — see [`crate::param::ParamList::get_uint32_strict`].
1120    pub fn get_uint32_param_strict(&self, index: usize, addr: i32) -> AsynResult<u32> {
1121        self.params.get_uint32_strict(index, addr)
1122    }
1123
1124    pub fn get_enum_param(&self, index: usize, addr: i32) -> AsynResult<(usize, Arc<[EnumEntry]>)> {
1125        self.params.get_enum(index, addr)
1126    }
1127
1128    pub fn set_enum_index_param(
1129        &mut self,
1130        index: usize,
1131        addr: i32,
1132        value: usize,
1133    ) -> AsynResult<()> {
1134        self.params.set_enum_index(index, addr, value)
1135    }
1136
1137    pub fn set_enum_choices_param(
1138        &mut self,
1139        index: usize,
1140        addr: i32,
1141        choices: Arc<[EnumEntry]>,
1142    ) -> AsynResult<()> {
1143        self.params.set_enum_choices(index, addr, choices)
1144    }
1145
1146    pub fn get_generic_pointer_param(
1147        &self,
1148        index: usize,
1149        addr: i32,
1150    ) -> AsynResult<Arc<dyn Any + Send + Sync>> {
1151        self.params.get_generic_pointer(index, addr)
1152    }
1153
1154    pub fn set_generic_pointer_param(
1155        &mut self,
1156        index: usize,
1157        addr: i32,
1158        value: Arc<dyn Any + Send + Sync>,
1159    ) -> AsynResult<()> {
1160        self.params.set_generic_pointer(index, addr, value)
1161    }
1162
1163    pub fn set_param_timestamp(
1164        &mut self,
1165        index: usize,
1166        addr: i32,
1167        ts: SystemTime,
1168    ) -> AsynResult<()> {
1169        self.params.set_timestamp(index, addr, ts)
1170    }
1171
1172    pub fn set_param_status(
1173        &mut self,
1174        index: usize,
1175        addr: i32,
1176        status: AsynStatus,
1177        alarm_status: u16,
1178        alarm_severity: u16,
1179    ) -> AsynResult<()> {
1180        self.params
1181            .set_param_status(index, addr, status, alarm_status, alarm_severity)
1182    }
1183
1184    pub fn get_param_status(&self, index: usize, addr: i32) -> AsynResult<(AsynStatus, u16, u16)> {
1185        self.params.get_param_status(index, addr)
1186    }
1187
1188    /// C `asynPortDriver::reportParams` (asynPortDriver.cpp:1799-1809) — the
1189    /// parameter block of the driver's report.
1190    ///
1191    /// `details` is the level `report` was called with, **unshifted**: C hands
1192    /// `reportParams` the same number it got (:3692), and the level decides one
1193    /// thing only — how many address lists are printed (`details >= 2` → all
1194    /// `maxAddr` of them, else list 0 alone, :1804). The values are *not* a
1195    /// deeper level: `paramVal::report` prints name, type, value and status for
1196    /// every parameter at every level (paramVal.cpp:296-330).
1197    ///
1198    /// Passing `level - 1` here put the whole block one level late — `asynReport
1199    /// 1` printed a bare count where C prints every parameter with its value, and
1200    /// values only appeared at 3 (R16-46/47).
1201    pub fn report_params(&self, out: &mut dyn std::fmt::Write, details: i32) {
1202        use std::fmt::Write as _;
1203        let num_addr = if details >= 2 {
1204            self.max_addr.max(1)
1205        } else {
1206            1
1207        };
1208        for addr in 0..num_addr {
1209            let _ = writeln!(out, "Parameter list {addr}");
1210            self.params.report(out, addr as i32);
1211        }
1212    }
1213
1214    /// Push an interpose layer onto the **port's** octet I/O stack — C
1215    /// `interposeInterface(portName, -1, ...)`, which every driver's own
1216    /// configure-time install is (the layer serves every device on the port).
1217    ///
1218    /// **Concurrency**: requires `&mut self`, which means the caller must hold
1219    /// the port lock (`Arc<Mutex<dyn PortDriver>>`). This ensures
1220    /// interpose modifications are serialized with I/O dispatch.
1221    pub fn install_octet_interpose(&mut self, layer: Box<dyn OctetInterpose>) {
1222        self.install_octet_interpose_addr(crate::interpose::PORT_CHAIN, layer);
1223    }
1224
1225    /// Push an interpose layer onto the stack of the device `addr` names — C
1226    /// `interposeInterface(portName, addr, ...)` (asynManager.c:2190-2220), which
1227    /// is what the `asynInterposeEcho` / `asynInterposeDelay` iocsh commands call
1228    /// (asynInterposeEcho.c:176, asynInterposeDelay.c:187,200). On a port that is
1229    /// not multi-device every addr resolves to the port itself.
1230    pub fn install_octet_interpose_addr(&mut self, addr: i32, layer: Box<dyn OctetInterpose>) {
1231        self.interpose_octet.install(addr, layer);
1232    }
1233
1234    /// Flush changed parameters as interrupt notifications.
1235    /// Equivalent to C asyn's callParamCallbacks().
1236    pub fn call_param_callbacks(&mut self, addr: i32) -> AsynResult<()> {
1237        let changed = self.params.take_changed(addr)?;
1238        let now = self.current_timestamp();
1239        for reason in changed {
1240            let value = self.params.get_value(reason, addr)?.clone();
1241            // C asynPortDriver.cpp:845 — callCallbacks skips firing for an
1242            // undefined param even though its changed flag is consumed
1243            // (flags.clear() at :871). A status/alarm change or bare
1244            // mark_changed on a never-set scalar must not emit an I/O Intr.
1245            // Array/generic-pointer params have no callCallbacks analog
1246            // (:846-865 switch is scalar-only) and Rust fires them as a
1247            // read-trigger regardless, so gate scalars only.
1248            if !value.is_array() && !self.params.is_param_defined(reason, addr).unwrap_or(false) {
1249                continue;
1250            }
1251            let ts = self.params.get_timestamp(reason, addr)?.unwrap_or(now);
1252            // C parity: read the accumulated callback mask and reset it
1253            // (asynPortDriver.cpp:854-855 fires uint32Callback then sets
1254            // uInt32CallbackMask = 0). The flush is the single owner of
1255            // this consume, so accumulated bits never leak to the next.
1256            let uint32_mask = self
1257                .params
1258                .take_uint32_interrupt_mask(reason, addr)
1259                .unwrap_or(0);
1260            // C parity: asynPortDriver.cpp:631-642 sets
1261            // `pInterrupt->pasynUser->auxStatus/alarmStatus/alarmSeverity`
1262            // from the param's stored status before invoking each
1263            // subscriber callback. Pull those here so subscribers see
1264            // the same triplet C consumers do.
1265            let (aux_status, alarm_status, alarm_severity) = self
1266                .params
1267                .get_param_status(reason, addr)
1268                .unwrap_or((AsynStatus::Success, 0, 0));
1269            self.interrupts.notify(InterruptValue {
1270                reason,
1271                addr,
1272                value,
1273                timestamp: ts,
1274                uint32_changed_mask: uint32_mask,
1275                aux_status,
1276                alarm_status,
1277                alarm_severity,
1278                // Untyped: a single cached value per (reason,addr) reaches
1279                // every subscribing interface (the pre-per-interface path).
1280                iface: None,
1281            });
1282        }
1283        Ok(())
1284    }
1285
1286    /// Flush a single parameter's changed flag and notify if dirty.
1287    /// Use this instead of `call_param_callbacks` when you want to avoid
1288    /// flushing unrelated parameters (e.g. rapidly-updating CP-linked params).
1289    pub fn call_param_callback(&mut self, addr: i32, reason: usize) -> AsynResult<()> {
1290        if self.params.take_changed_single(reason, addr)? {
1291            let value = self.params.get_value(reason, addr)?.clone();
1292            // C asynPortDriver.cpp:845 — see `call_param_callbacks`: an
1293            // undefined scalar consumes its changed flag but fires no
1294            // callback. Array/generic-pointer triggers fire regardless.
1295            if !value.is_array() && !self.params.is_param_defined(reason, addr).unwrap_or(false) {
1296                return Ok(());
1297            }
1298            let now = self.current_timestamp();
1299            let ts = self.params.get_timestamp(reason, addr)?.unwrap_or(now);
1300            // C parity: read the accumulated callback mask and reset it
1301            // (asynPortDriver.cpp:854-855 fires uint32Callback then sets
1302            // uInt32CallbackMask = 0). The flush is the single owner of
1303            // this consume, so accumulated bits never leak to the next.
1304            let uint32_mask = self
1305                .params
1306                .take_uint32_interrupt_mask(reason, addr)
1307                .unwrap_or(0);
1308            // C parity: see `call_param_callbacks` above.
1309            let (aux_status, alarm_status, alarm_severity) = self
1310                .params
1311                .get_param_status(reason, addr)
1312                .unwrap_or((AsynStatus::Success, 0, 0));
1313            self.interrupts.notify(InterruptValue {
1314                reason,
1315                addr,
1316                value,
1317                timestamp: ts,
1318                uint32_changed_mask: uint32_mask,
1319                aux_status,
1320                alarm_status,
1321                alarm_severity,
1322                // Untyped (see `call_param_callbacks`).
1323                iface: None,
1324            });
1325        }
1326        Ok(())
1327    }
1328
1329    /// Mark a parameter as changed without modifying its value.
1330    ///
1331    /// Use this to trigger I/O Intr on params whose data is served via
1332    /// `read_*_array()` overrides rather than the param cache (e.g. pixel data).
1333    pub fn mark_param_changed(&mut self, index: usize, addr: i32) -> AsynResult<()> {
1334        self.params.mark_changed(index, addr)
1335    }
1336
1337    /// Fire one per-interface I/O Intr callback carrying an interface-typed value.
1338    ///
1339    /// `call_param_callbacks` stores **one** value per `(reason, addr)` and
1340    /// notifies it untyped, so every record on that reason — whatever its DTYP's
1341    /// interface — receives the same value. For a driver whose single raw datum
1342    /// is exposed on several asyn interfaces at once (e.g. a Modbus register read
1343    /// by an `asynInt32` ai, an `asynUInt32Digital` bi, and an `asynFloat64` ai
1344    /// simultaneously), that collapse delivers a wrong-typed value to all but one
1345    /// of them. C's `drvModbusAsyn::readPoller` instead decodes the one register
1346    /// block **separately per interface** and invokes each interface's own
1347    /// interrupt list (`int32`/`uInt32Digital`/`float64`,
1348    /// drvModbusAsyn.cpp:1706/1736/1808). This is the analogue: the driver
1349    /// decodes per interface and fires each value tagged with its `iface`, so the
1350    /// interrupt filter routes it only to records on that interface
1351    /// ([`InterruptFilter::iface`](crate::interrupt::InterruptFilter::iface)). `uint32_changed_mask` is the changed-bit
1352    /// mask for the `UInt32Digital` interface (a record's `@asynMask` gates on it,
1353    /// `asynPortDriver.cpp:720`); pass `0` for the other interfaces, whose
1354    /// subscribers carry no mask filter.
1355    ///
1356    /// `aux_status` is the device I/O status this fire carries (C
1357    /// `pInterrupt->pasynUser->auxStatus`, set on every callback the poller
1358    /// emits — `drvModbusAsyn.cpp:1697/1738/1774/1810/1880/1915`). A driver whose
1359    /// last acquisition failed still fires its interrupt lists, with the failing
1360    /// status, so I/O-Intr records go to READ/INVALID instead of freezing on the
1361    /// last good value; pass [`AsynStatus::Success`] on a clean acquisition.
1362    pub fn notify_interface_value(
1363        &self,
1364        reason: usize,
1365        addr: i32,
1366        iface: InterfaceType,
1367        value: ParamValue,
1368        uint32_changed_mask: u32,
1369        aux_status: AsynStatus,
1370    ) {
1371        let ts = self.current_timestamp();
1372        self.interrupts.notify(InterruptValue {
1373            reason,
1374            addr,
1375            value,
1376            timestamp: ts,
1377            uint32_changed_mask,
1378            aux_status,
1379            alarm_status: 0,
1380            alarm_severity: 0,
1381            iface: Some(iface),
1382        });
1383    }
1384}
1385
1386/// Result of resolving a record's driver-info string at bind time — the
1387/// asyn-rs analogue of what C `drvUserCreate` writes into `pasynUser`.
1388///
1389/// `reason` is the shared parameter index (every record with the same drvInfo
1390/// resolves to it). The remaining fields carry **per-record** driver state the
1391/// lookup derived from this particular drvInfo string (C stashes the same in
1392/// `pasynUser->drvUser`), which the binding applies to that record's I/O.
1393#[derive(Debug, Default)]
1394pub struct DrvUserInfo {
1395    /// Shared parameter index for this drvInfo (C `pasynUser->reason`).
1396    pub reason: usize,
1397    /// Optional per-record octet length cap — the asyn-rs home for C's
1398    /// `modbusDrvUser_t.len` (`drvUserCreate` parses `TYPE=N`; `getStringLen`
1399    /// caps the asyn octet `maxLen` to it, drvModbusAsyn.cpp:2367-2377). `None`
1400    /// when the drvInfo carried no cap; the binding then uses the record buffer
1401    /// length alone. The binding applies `min(buffer_len, cap)`.
1402    pub max_octet_len: Option<usize>,
1403}
1404
1405impl DrvUserInfo {
1406    /// A resolution carrying only the shared reason and no per-record cap — the
1407    /// default-lookup result.
1408    pub fn from_reason(reason: usize) -> Self {
1409        Self {
1410            reason,
1411            ..Self::default()
1412        }
1413    }
1414}
1415
1416/// Everything a binding tells the driver when it resolves a drvInfo string —
1417/// the asyn-rs analogue of the `pasynUser` C `drvUserCreate` receives.
1418///
1419/// This is one carrier rather than a widening argument list because the bind
1420/// request has already grown once (`addr`, for C `checkOffset`) and is now
1421/// growing again (`iface`): a driver that resolves a drvInfo needs to know
1422/// *how the record will use the parameter*, not just its name. The struct is
1423/// `#[non_exhaustive]` so the next field is additive — build it with
1424/// [`DrvUserRequest::new`] and the builder methods.
1425#[derive(Debug, Clone, PartialEq, Eq)]
1426#[non_exhaustive]
1427pub struct DrvUserRequest {
1428    /// The record's driver-info string (C `pasynUser->drvUser` input).
1429    pub drv_info: String,
1430    /// The record's asyn `addr`, so a multi-device driver can reject an
1431    /// out-of-range address at bind time (C `drvUserCreate` `checkOffset`,
1432    /// drvModbusAsyn.cpp:378-384).
1433    pub addr: i32,
1434    /// The asyn interface the record will actually read and write this
1435    /// parameter through, derived from its DTYP (`asynFloat64` → [`InterfaceType::Float64`]).
1436    ///
1437    /// An on-demand driver (C Autoparam lazy creation) must create the
1438    /// parameter with the type the record will read it as, or every value the
1439    /// record takes from it is a coerced type mismatch: C
1440    /// `adsAsynPortDriver::getRecordInfoFromDrvInfo` derives the parameter's
1441    /// asyn type from the bound record's DTYP for exactly this reason — the
1442    /// same PLC symbol may bind as `asynInt32` from one record and
1443    /// `asynFloat64` from another.
1444    ///
1445    /// `None` when the bind has no record behind it (a port-level
1446    /// [`crate::sync_io`] resolve, a driver-to-driver handle call): the driver
1447    /// then has no record type to honour and falls back to its own default.
1448    pub iface: Option<InterfaceType>,
1449}
1450
1451impl DrvUserRequest {
1452    /// A bind request for `drv_info` at `addr` with no record interface — the
1453    /// port-level resolve.
1454    pub fn new(drv_info: impl Into<String>, addr: i32) -> Self {
1455        Self {
1456            drv_info: drv_info.into(),
1457            addr,
1458            iface: None,
1459        }
1460    }
1461
1462    /// Attach the bound record's asyn interface. Accepts an
1463    /// [`InterfaceType`] or an `Option<InterfaceType>`.
1464    pub fn with_iface(mut self, iface: impl Into<Option<InterfaceType>>) -> Self {
1465        self.iface = iface.into();
1466        self
1467    }
1468}
1469
1470/// Port driver trait. All methods have default implementations that operate
1471/// on the parameter cache (no actual I/O).
1472///
1473/// Drivers performing real hardware I/O should:
1474/// 1. Run I/O in a background task (e.g., tokio::spawn)
1475/// 2. Update parameters via `base_mut().set_*_param()` + `call_param_callbacks()`
1476/// 3. Let the default `read_*` methods return cached values
1477///
1478/// # LockPort/UnlockPort
1479///
1480/// C asyn provides `lockPort`/`unlockPort` for direct mutex locking. In asyn-rs,
1481/// the port is always behind `Arc<Mutex<dyn PortDriver>>`, so callers hold the
1482/// parking_lot mutex directly. For multi-request exclusive access, use
1483/// `BlockProcess`/`UnblockProcess` via the worker queue.
1484/// C `epicsTimeToStrftime(buff, ..., "%Y/%m/%d %H:%M:%S.%03f", &timeStamp)` —
1485/// the port timestamp line of `asynPortDriver::report` (asynPortDriver.cpp:3682-3684).
1486/// Local time, as `epicsTimeToStrftime` renders it.
1487fn format_timestamp(ts: SystemTime) -> String {
1488    chrono::DateTime::<chrono::Local>::from(ts)
1489        .format("%Y/%m/%d %H:%M:%S%.3f")
1490        .to_string()
1491}
1492
1493/// C's `details >= 3` block: one line per registered interrupt client
1494/// (`reportInterrupt`, asynPortDriver.cpp:1870-1894, called once per interface at
1495/// :3695-3708). C prints the callback and userPvt pointers with each client; Rust
1496/// mailboxes have no such pointers, so the line carries what identifies a client
1497/// here — the interface it bound, the address and reason it filtered on, and the
1498/// uint32 mask when it set one.
1499fn report_interrupt_clients(out: &mut dyn std::fmt::Write, base: &PortDriverBase) {
1500    use std::fmt::Write as _;
1501    for f in base.interrupts.clients() {
1502        let iface = f
1503            .iface
1504            .map_or("any", |i: crate::interfaces::InterfaceType| {
1505                i.interrupt_label()
1506            });
1507        let addr = f.addr.map_or("any".to_string(), |a| a.to_string());
1508        let reason = f.reason.map_or("any".to_string(), |r| r.to_string());
1509        let _ = write!(
1510            out,
1511            "    {iface} callback client addr={addr}, reason={reason}"
1512        );
1513        if let Some(mask) = f.uint32_mask {
1514            let _ = write!(out, ", mask=0x{mask:x}");
1515        }
1516        let _ = writeln!(out);
1517    }
1518}
1519
1520pub trait PortDriver: Send + Sync + 'static {
1521    fn base(&self) -> &PortDriverBase;
1522    fn base_mut(&mut self) -> &mut PortDriverBase;
1523
1524    // --- AsynCommon ---
1525
1526    fn connect(&mut self, _user: &AsynUser) -> AsynResult<()> {
1527        // Single owner-API: edge-guarded fire is in PortDriverBase::set_connected.
1528        self.base_mut().set_connected(true);
1529        Ok(())
1530    }
1531
1532    fn disconnect(&mut self, _user: &AsynUser) -> AsynResult<()> {
1533        self.base_mut().set_connected(false);
1534        Ok(())
1535    }
1536
1537    fn enable(&mut self, _user: &AsynUser) -> AsynResult<()> {
1538        // C `enable` refuses a defunct port (asynManager.c:2236-2241);
1539        // the guard lives in the single owner.
1540        self.base_mut().set_enabled(true)
1541    }
1542
1543    fn disable(&mut self, _user: &AsynUser) -> AsynResult<()> {
1544        self.base_mut().set_enabled(false)
1545    }
1546
1547    fn connect_addr(&mut self, user: &AsynUser) -> AsynResult<()> {
1548        self.base_mut().connect_addr(user.addr);
1549        Ok(())
1550    }
1551
1552    fn disconnect_addr(&mut self, user: &AsynUser) -> AsynResult<()> {
1553        self.base_mut().disconnect_addr(user.addr);
1554        Ok(())
1555    }
1556
1557    fn enable_addr(&mut self, user: &AsynUser) -> AsynResult<()> {
1558        // Guarded owner — propagates asynDisabled on a defunct port.
1559        self.base_mut().set_addr_enabled(user.addr, true)
1560    }
1561
1562    fn disable_addr(&mut self, user: &AsynUser) -> AsynResult<()> {
1563        self.base_mut().set_addr_enabled(user.addr, false)
1564    }
1565
1566    fn get_option(&self, key: &str) -> AsynResult<String> {
1567        self.base()
1568            .options
1569            .get(key)
1570            .cloned()
1571            .ok_or_else(|| AsynError::OptionNotFound(key.to_string()))
1572    }
1573
1574    /// C `asynOption::setOption(void *drvPvt, asynUser *pasynUser, key, val)`.
1575    ///
1576    /// `user` is the caller's, and its `timeout` is the one that bounds any wire
1577    /// traffic the option write causes — an RFC 2217 negotiation on a COM port
1578    /// runs under it (`asynInterposeCom.c:475,495`). The option layer has no
1579    /// timeout of its own: an asynRecord option put negotiates under TMOT, an
1580    /// iocsh `asynSetOption` under its own 2 s (`asynShellCommands.c:119`).
1581    fn set_option(&mut self, _user: &mut AsynUser, key: &str, value: &str) -> AsynResult<()> {
1582        self.base_mut()
1583            .options
1584            .insert(key.to_string(), value.to_string());
1585        Ok(())
1586    }
1587
1588    /// The driver's own report — C `asynCommon::report`, which the manager calls
1589    /// last (`reportPrintPort`, asynManager.c:1113-1122) after printing the port's
1590    /// manager-level state itself.
1591    ///
1592    /// So this prints only what the *driver* owns. The port's enable / connect /
1593    /// queue / lock / exception / trace state is the manager's to print and is
1594    /// printed by `crate::port_actor::PortActor::report_port`; duplicating it
1595    /// here would give the operator two answers to the same question, from two
1596    /// owners, with no rule for which one wins.
1597    ///
1598    /// The default is C++ `asynPortDriver::report` (asynPortDriver.cpp:3676-3710):
1599    /// the port name; at `details >= 1` the timestamp, the EOS terminators *if the
1600    /// driver registered the octet interface* (`pasynStdInterfaces->octet.pinterface`,
1601    /// :3685) and the parameter library; at `details >= 3` the interrupt clients
1602    /// (:3695-3708).
1603    ///
1604    /// The report goes to `out`, C's `FILE *fp` — the driver never picks the
1605    /// stream. `crate::port_actor::PortActor::report_port` is the one owner that
1606    /// does, and it picks stdout, as `asynReport` does (asynShellCommands.c:589).
1607    fn report(&self, out: &mut dyn std::fmt::Write, level: i32) {
1608        use std::fmt::Write as _;
1609        let base = self.base();
1610        let _ = writeln!(out, "Port: {}", base.port_name);
1611        if level >= 1 {
1612            let _ = writeln!(
1613                out,
1614                "  Timestamp: {}",
1615                format_timestamp(base.current_timestamp())
1616            );
1617            // C prints the EOS pair only when the driver registered `asynOctet`
1618            // (:3685) — on a port with no octet interface the two terminators are
1619            // the constructor's zeroed fields, and reporting them invents an EOS
1620            // the port cannot have.
1621            if self.has_octet_interface() {
1622                // C escapes the terminator with `epicsStrPrintEscaped` (:3687,
1623                // :3690) — the whole libCom table, not just CR and LF. A private
1624                // two-case table wrote a binary terminator (`\x03`, ESC, TAB, NUL)
1625                // raw into stdout (R16-48); [`crate::escape`] is the one owner.
1626                let input = base.input_eos(0);
1627                let output = base.output_eos(0);
1628                let _ = writeln!(
1629                    out,
1630                    "  Input EOS[{}]: {}",
1631                    input.len(),
1632                    crate::escape::print_escaped(input)
1633                );
1634                let _ = writeln!(
1635                    out,
1636                    "  Output EOS[{}]: {}",
1637                    output.len(),
1638                    crate::escape::print_escaped(output)
1639                );
1640            }
1641            // C hands its own level straight to `reportParams` (:3692).
1642            base.report_params(out, level);
1643        }
1644        // There is no options block: `asynPortDriver::report` never prints one
1645        // (asynPortDriver.cpp:3677-3710), and it could not — `asynOption` is a
1646        // get/set pair keyed by a string the *driver* defines, with nothing to
1647        // enumerate. The `option: k = v` lines this printed at `details >= 2` had
1648        // no C source and no fixed key set to be complete over: they listed
1649        // whatever happened to have been written through `setOption`, which is not
1650        // the port's option state (R16-49).
1651        if level >= 3 {
1652            report_interrupt_clients(out, base);
1653        }
1654    }
1655
1656    /// Whether this driver registered `asynOctet` — C's
1657    /// `pasynStdInterfaces->octet.pinterface != NULL`, which is set from the
1658    /// constructor's `interfaceMask` (asynPortDriver.cpp:1990). The Rust analogue
1659    /// of that mask is [`Self::capabilities`].
1660    fn has_octet_interface(&self) -> bool {
1661        self.capabilities()
1662            .iter()
1663            .any(|c| c.interface_type() == crate::interfaces::InterfaceType::Octet)
1664    }
1665
1666    // --- Scalar I/O (cache-based defaults, timeout not applicable) ---
1667
1668    // Cache-based defaults do NOT check connection state (C parity).
1669    // The port actor checks check_ready_addr() before dispatching, matching
1670    // C asyn where asynManager checks connection before calling the driver.
1671
1672    // Default reads use the STRICT getter: an undefined parameter must
1673    // surface as ParamUndefined, not success/0. C parity — the default
1674    // asynPortDriver::read{Int32,Int64,Float64,Octet,UInt32Digital}
1675    // (asynPortDriver.cpp) calls get{Integer,Integer64,Double,String,
1676    // UIntDigital}Param, and every paramVal getter throws
1677    // ParamValNotDefined → asynParamUndefined for an unset value
1678    // (paramVal.cpp:152,181,235,264,292). devAsyn* then routes that status
1679    // through asynStatusToEpicsAlarm(READ_ALARM, INVALID_ALARM) instead of
1680    // updating RVAL/clearing UDF (e.g. devAsynUInt32Digital.c:898-901,
1681    // devAsynInt32.c:844-847). The lax get_*_param accessors stay for
1682    // internal callers that explicitly want default-zero behavior.
1683
1684    fn read_int32(&mut self, user: &AsynUser) -> AsynResult<i32> {
1685        self.base().params.get_int32_strict(user.reason, user.addr)
1686    }
1687
1688    fn write_int32(&mut self, user: &mut AsynUser, value: i32) -> AsynResult<()> {
1689        self.base_mut()
1690            .params
1691            .set_int32(user.reason, user.addr, value)?;
1692        self.base_mut().call_param_callbacks(user.addr)
1693    }
1694
1695    fn read_int64(&mut self, user: &AsynUser) -> AsynResult<i64> {
1696        self.base().params.get_int64_strict(user.reason, user.addr)
1697    }
1698
1699    fn write_int64(&mut self, user: &mut AsynUser, value: i64) -> AsynResult<()> {
1700        self.base_mut()
1701            .params
1702            .set_int64(user.reason, user.addr, value)?;
1703        self.base_mut().call_param_callbacks(user.addr)
1704    }
1705
1706    /// C `asynInt32Base.c:99` default: report `low = high = 0` so a
1707    /// driver that does not implement getBounds makes convertAi/convertAo
1708    /// skip the LINEAR ESLO/EOFF computation (`devAsynInt32.c:444`).
1709    fn get_bounds_int32(&self, _user: &AsynUser) -> AsynResult<(i32, i32)> {
1710        Ok((0, 0))
1711    }
1712
1713    /// C `asynInt64Base.c:99` default: report `low = high = 0` (see
1714    /// `get_bounds_int32`).
1715    fn get_bounds_int64(&self, _user: &AsynUser) -> AsynResult<(i64, i64)> {
1716        Ok((0, 0))
1717    }
1718
1719    fn read_float64(&mut self, user: &AsynUser) -> AsynResult<f64> {
1720        self.base()
1721            .params
1722            .get_float64_strict(user.reason, user.addr)
1723    }
1724
1725    fn write_float64(&mut self, user: &mut AsynUser, value: f64) -> AsynResult<()> {
1726        self.base_mut()
1727            .params
1728            .set_float64(user.reason, user.addr, value)?;
1729        self.base_mut().call_param_callbacks(user.addr)
1730    }
1731
1732    fn read_octet(&mut self, user: &AsynUser, buf: &mut [u8]) -> AsynResult<usize> {
1733        let s = self
1734            .base()
1735            .params
1736            .get_string_strict(user.reason, user.addr)?;
1737        let bytes = s.as_bytes();
1738        let n = bytes.len().min(buf.len());
1739        buf[..n].copy_from_slice(&bytes[..n]);
1740        Ok(n)
1741    }
1742
1743    fn write_octet(&mut self, user: &mut AsynUser, data: &[u8]) -> AsynResult<usize> {
1744        let s = String::from_utf8_lossy(data).into_owned();
1745        self.base_mut()
1746            .params
1747            .set_string(user.reason, user.addr, s)?;
1748        self.base_mut().call_param_callbacks(user.addr)?;
1749        Ok(data.len())
1750    }
1751
1752    fn read_uint32_digital(&mut self, user: &AsynUser, mask: u32) -> AsynResult<u32> {
1753        let val = self
1754            .base()
1755            .params
1756            .get_uint32_strict(user.reason, user.addr)?;
1757        Ok(val & mask)
1758    }
1759
1760    fn write_uint32_digital(
1761        &mut self,
1762        user: &mut AsynUser,
1763        value: u32,
1764        mask: u32,
1765    ) -> AsynResult<()> {
1766        // The asynUInt32Digital write interface carries no forced interrupt
1767        // mask — changed bits derive from value^old (interrupt_mask = 0).
1768        self.base_mut()
1769            .params
1770            .set_uint32(user.reason, user.addr, value, mask, 0)?;
1771        self.base_mut().call_param_callbacks(user.addr)
1772    }
1773
1774    /// Configure rising / falling interrupt masks for a
1775    /// UInt32Digital parameter. C parity:
1776    /// `asynPortDriver::setInterruptUInt32Digital`
1777    /// (`asynPortDriver.cpp:2346-2369`) → routes to
1778    /// `paramList::setUInt32Interrupt`. The default delegates to the
1779    /// param store; drivers that need to push the configuration to
1780    /// hardware (e.g. real GPIB cards toggling SRQ enable) override
1781    /// it.
1782    fn set_interrupt_uint32_digital(
1783        &mut self,
1784        user: &AsynUser,
1785        mask: u32,
1786        reason: InterruptReason,
1787    ) -> AsynResult<()> {
1788        self.base_mut()
1789            .params
1790            .set_uint32_interrupt(user.reason, user.addr, mask, reason)
1791    }
1792
1793    /// Clear bits from rising AND falling masks. C parity:
1794    /// `asynPortDriver::clearInterruptUInt32Digital`
1795    /// (`asynPortDriver.cpp:2392-2415`). Mirrors C — the call does
1796    /// not take an `interruptReason`; both masks are cleared.
1797    fn clear_interrupt_uint32_digital(&mut self, user: &AsynUser, mask: u32) -> AsynResult<()> {
1798        self.base_mut()
1799            .params
1800            .clear_uint32_interrupt(user.reason, user.addr, mask)
1801    }
1802
1803    /// Read the configured rising / falling / combined mask. C
1804    /// parity: `asynPortDriver::getInterruptUInt32Digital`
1805    /// (`asynPortDriver.cpp:2438-2461`).
1806    fn get_interrupt_uint32_digital(
1807        &self,
1808        user: &AsynUser,
1809        reason: InterruptReason,
1810    ) -> AsynResult<u32> {
1811        self.base()
1812            .params
1813            .get_uint32_interrupt(user.reason, user.addr, reason)
1814    }
1815
1816    // --- Enum I/O (cache-based defaults) ---
1817
1818    fn read_enum(&mut self, user: &AsynUser) -> AsynResult<(usize, Arc<[EnumEntry]>)> {
1819        self.base().params.get_enum(user.reason, user.addr)
1820    }
1821
1822    fn write_enum(&mut self, user: &mut AsynUser, index: usize) -> AsynResult<()> {
1823        self.base_mut()
1824            .params
1825            .set_enum_index(user.reason, user.addr, index)?;
1826        self.base_mut().call_param_callbacks(user.addr)
1827    }
1828
1829    fn write_enum_choices(
1830        &mut self,
1831        user: &mut AsynUser,
1832        choices: Arc<[EnumEntry]>,
1833    ) -> AsynResult<()> {
1834        self.base_mut()
1835            .params
1836            .set_enum_choices(user.reason, user.addr, choices)?;
1837        self.base_mut().call_param_callbacks(user.addr)
1838    }
1839
1840    // --- GenericPointer I/O (cache-based defaults) ---
1841
1842    fn read_generic_pointer(&mut self, user: &AsynUser) -> AsynResult<Arc<dyn Any + Send + Sync>> {
1843        self.base()
1844            .params
1845            .get_generic_pointer(user.reason, user.addr)
1846    }
1847
1848    fn write_generic_pointer(
1849        &mut self,
1850        user: &mut AsynUser,
1851        value: Arc<dyn Any + Send + Sync>,
1852    ) -> AsynResult<()> {
1853        self.base_mut()
1854            .params
1855            .set_generic_pointer(user.reason, user.addr, value)?;
1856        self.base_mut().call_param_callbacks(user.addr)
1857    }
1858
1859    // --- Array I/O (default: not supported) ---
1860
1861    fn read_float64_array(&mut self, _user: &AsynUser, _buf: &mut [f64]) -> AsynResult<usize> {
1862        Err(AsynError::InterfaceNotSupported("asynFloat64Array".into()))
1863    }
1864
1865    fn write_float64_array(&mut self, user: &AsynUser, data: &[f64]) -> AsynResult<()> {
1866        self.base_mut()
1867            .params
1868            .set_float64_array(user.reason, user.addr, data.to_vec())?;
1869        self.base_mut().call_param_callbacks(user.addr)
1870    }
1871
1872    fn read_int32_array(&mut self, _user: &AsynUser, _buf: &mut [i32]) -> AsynResult<usize> {
1873        Err(AsynError::InterfaceNotSupported("asynInt32Array".into()))
1874    }
1875
1876    fn write_int32_array(&mut self, user: &AsynUser, data: &[i32]) -> AsynResult<()> {
1877        self.base_mut()
1878            .params
1879            .set_int32_array(user.reason, user.addr, data.to_vec())?;
1880        self.base_mut().call_param_callbacks(user.addr)
1881    }
1882
1883    fn read_int8_array(&mut self, _user: &AsynUser, _buf: &mut [i8]) -> AsynResult<usize> {
1884        Err(AsynError::InterfaceNotSupported("asynInt8Array".into()))
1885    }
1886
1887    fn write_int8_array(&mut self, user: &AsynUser, data: &[i8]) -> AsynResult<()> {
1888        self.base_mut()
1889            .params
1890            .set_int8_array(user.reason, user.addr, data.to_vec())?;
1891        self.base_mut().call_param_callbacks(user.addr)
1892    }
1893
1894    fn read_int16_array(&mut self, _user: &AsynUser, _buf: &mut [i16]) -> AsynResult<usize> {
1895        Err(AsynError::InterfaceNotSupported("asynInt16Array".into()))
1896    }
1897
1898    fn write_int16_array(&mut self, user: &AsynUser, data: &[i16]) -> AsynResult<()> {
1899        self.base_mut()
1900            .params
1901            .set_int16_array(user.reason, user.addr, data.to_vec())?;
1902        self.base_mut().call_param_callbacks(user.addr)
1903    }
1904
1905    fn read_int64_array(&mut self, _user: &AsynUser, _buf: &mut [i64]) -> AsynResult<usize> {
1906        Err(AsynError::InterfaceNotSupported("asynInt64Array".into()))
1907    }
1908
1909    fn write_int64_array(&mut self, user: &AsynUser, data: &[i64]) -> AsynResult<()> {
1910        self.base_mut()
1911            .params
1912            .set_int64_array(user.reason, user.addr, data.to_vec())?;
1913        self.base_mut().call_param_callbacks(user.addr)
1914    }
1915
1916    fn read_float32_array(&mut self, _user: &AsynUser, _buf: &mut [f32]) -> AsynResult<usize> {
1917        Err(AsynError::InterfaceNotSupported("asynFloat32Array".into()))
1918    }
1919
1920    fn write_float32_array(&mut self, user: &AsynUser, data: &[f32]) -> AsynResult<()> {
1921        self.base_mut()
1922            .params
1923            .set_float32_array(user.reason, user.addr, data.to_vec())?;
1924        self.base_mut().call_param_callbacks(user.addr)
1925    }
1926
1927    // --- I/O methods (worker thread calls these) ---
1928    // Default: delegate to cache-based read_*/write_* for backward compat.
1929    // Real I/O drivers override these for actual hardware access.
1930
1931    fn io_read_octet(&mut self, user: &AsynUser, buf: &mut [u8]) -> AsynResult<usize> {
1932        self.read_octet(user, buf)
1933    }
1934
1935    /// Octet read that also reports the end-of-message reason — C
1936    /// parity for `asynOctet::read(... int *eomReason)`
1937    /// (`asynOctet.h:38-40`). The default implementation delegates to
1938    /// [`Self::io_read_octet`] and reconstructs a synthetic
1939    /// [`EomReason`]: `CNT` when the buffer filled, `empty` otherwise.
1940    /// Drivers that have native EOM information
1941    /// (`asynOctetSyncIO::readRaw`, GPIB END, EOS match) must
1942    /// override this method so consumers — `asynRecord::EOMR`,
1943    /// `asynOctetSyncIO::readRaw` mirrors — receive the real flags.
1944    fn io_read_octet_eom(
1945        &mut self,
1946        user: &AsynUser,
1947        buf: &mut [u8],
1948    ) -> AsynResult<(usize, EomReason)> {
1949        let cap = buf.len();
1950        let n = self.io_read_octet(user, buf)?;
1951        let eom = if n >= cap && cap > 0 {
1952            EomReason::CNT
1953        } else {
1954            EomReason::empty()
1955        };
1956        Ok((n, eom))
1957    }
1958
1959    fn io_write_octet(&mut self, user: &mut AsynUser, data: &[u8]) -> AsynResult<usize> {
1960        self.write_octet(user, data)
1961    }
1962
1963    fn io_read_int32(&mut self, user: &AsynUser) -> AsynResult<i32> {
1964        self.read_int32(user)
1965    }
1966
1967    fn io_write_int32(&mut self, user: &mut AsynUser, value: i32) -> AsynResult<()> {
1968        self.write_int32(user, value)
1969    }
1970
1971    fn io_read_int64(&mut self, user: &AsynUser) -> AsynResult<i64> {
1972        self.read_int64(user)
1973    }
1974
1975    fn io_write_int64(&mut self, user: &mut AsynUser, value: i64) -> AsynResult<()> {
1976        self.write_int64(user, value)
1977    }
1978
1979    fn io_read_float64(&mut self, user: &AsynUser) -> AsynResult<f64> {
1980        self.read_float64(user)
1981    }
1982
1983    fn io_write_float64(&mut self, user: &mut AsynUser, value: f64) -> AsynResult<()> {
1984        self.write_float64(user, value)
1985    }
1986
1987    fn io_read_uint32_digital(&mut self, user: &AsynUser, mask: u32) -> AsynResult<u32> {
1988        self.read_uint32_digital(user, mask)
1989    }
1990
1991    fn io_write_uint32_digital(
1992        &mut self,
1993        user: &mut AsynUser,
1994        value: u32,
1995        mask: u32,
1996    ) -> AsynResult<()> {
1997        self.write_uint32_digital(user, value, mask)
1998    }
1999
2000    fn io_flush(&mut self, _user: &mut AsynUser) -> AsynResult<()> {
2001        Ok(())
2002    }
2003
2004    // --- Octet EOS (delegates to interpose stack by default) ---
2005    //
2006    // ## EOS connect-wait policy (C asyn issue #103)
2007    //
2008    // C asyn `asynOctetSyncIO::setInputEos` / `setOutputEos`
2009    // (`asynOctetSyncIO.c:300-321`, 346-367) call `lockPort` ahead of
2010    // the actual `setInputEos` — `lockPort` waits up to the user's
2011    // timeout for the port to be connected, by `epicsEventWait`-ing
2012    // on the connect event published from `connectIt`. On IOC init
2013    // and exit this serialises EOS configuration against the connect
2014    // task, but it also means a `setInputEos` issued before the port
2015    // has ever connected blocks the calling thread (issue #103
2016    // captured the symptom: IOC startup pauses for the full asyn
2017    // timeout when the device is off-line).
2018    //
2019    // The Rust path here is purely in-memory: `set_input_eos` and
2020    // `set_output_eos` write the bytes into `PortDriverBase` and the
2021    // EOS interpose stack reads from those fields at next read/write
2022    // time. No connect-wait, no lock contention with the connect
2023    // task — so issue #103's symptom cannot reproduce. If a future
2024    // refactor introduces a connect-gated EOS path (e.g. a driver
2025    // that owns the EOS state inside its connect()-allocated
2026    // resource), authors MUST keep the wait optional / bounded so
2027    // the connect-wait failure mode doesn't return.
2028
2029    // Every hook takes the `asynUser`, because in C every one of them does
2030    // (`asynOctet::setInputEos(void *ppvt, asynUser *pasynUser, ...)`,
2031    // asynOctetBase.h; asynInterposeEos.c:288-296) and the addr it carries is
2032    // what picks the device's terminator. A port-wide EOS could not hold two.
2033
2034    fn set_input_eos(&mut self, user: &AsynUser, eos: &[u8]) -> AsynResult<()> {
2035        if eos.len() > 2 {
2036            return Err(AsynError::Status {
2037                status: AsynStatus::Error,
2038                message: format!("illegal eoslen {}", eos.len()),
2039            });
2040        }
2041        // Single write owner for input EOS: the per-device cache is what
2042        // `get_input_eos` and the binary-suppress save/restore read, and the
2043        // same value is forwarded to the interpose stack so an installed
2044        // `EosInterpose` actually terminates *this device's* reads on it. Empty
2045        // stack = no-op forward; C routes `setInputEos` the same way.
2046        let addr = user.addr;
2047        let base = self.base_mut();
2048        base.eos_entry(addr).input = eos.to_vec();
2049        base.interpose_octet.set_input_eos(addr, eos);
2050        Ok(())
2051    }
2052
2053    fn get_input_eos(&self, user: &AsynUser) -> Vec<u8> {
2054        self.base().input_eos(user.addr).to_vec()
2055    }
2056
2057    fn set_output_eos(&mut self, user: &AsynUser, eos: &[u8]) -> AsynResult<()> {
2058        if eos.len() > 2 {
2059            return Err(AsynError::Status {
2060                status: AsynStatus::Error,
2061                message: format!("illegal eoslen {}", eos.len()),
2062            });
2063        }
2064        // Single write owner for output EOS (see `set_input_eos`): cache per
2065        // device and forward to the interpose stack so `EosInterpose` appends
2066        // the terminator on that device's writes.
2067        let addr = user.addr;
2068        let base = self.base_mut();
2069        base.eos_entry(addr).output = eos.to_vec();
2070        base.interpose_octet.set_output_eos(addr, eos);
2071        Ok(())
2072    }
2073
2074    fn get_output_eos(&self, user: &AsynUser) -> Vec<u8> {
2075        self.base().output_eos(user.addr).to_vec()
2076    }
2077
2078    // --- asynGpib (IEEE-488 bus control) ---
2079    //
2080    // The four command methods of C's `asynGpib` interface (asynGpibDriver.h:47-51),
2081    // which asynGpib.c passes straight through to the driver's `asynGpibPort`
2082    // (asynGpib.c:472-496). A driver that implements them declares
2083    // [`crate::interfaces::Capability::Gpib`], and that declaration is what a
2084    // client's `findInterface(asynGpibType)` answers — asynRecord reads it into
2085    // GPIBIV and refuses UCMD/ACMD when it is 0 (asynRecord.c:1231-1241,
2086    // :1647-1651).
2087    //
2088    // The defaults refuse: a port that has not declared the capability can only
2089    // be reached here by a caller that skipped the registry, and C has nothing
2090    // to call in that case (the interface pointer is NULL).
2091    //
2092    // Not ported: the `asynGpibPort` methods that exist solely to drive
2093    // asynGpib's SRQ poll thread — `srqStatus`, `srqEnable`, `serialPollBegin`,
2094    // `serialPoll`, `serialPollEnd` (asynGpibDriver.h:88-92), plus `pollAddr` /
2095    // `srqHappened` (asynGpib.c:498-559, 633-656). Nothing in this tree polls
2096    // SRQ; asynRecord's own "Serial Poll" ACMD does not use them (it sends SPE,
2097    // reads one octet, sends SPD — asynRecord.c:1717-1746).
2098
2099    /// C `asynGpib::universalCmd` — send one universal command byte with ATN
2100    /// asserted (asynGpib.c:480-484, `vxiUniversalCmd` drvVxi11.c:1406-1424).
2101    fn gpib_universal_cmd(&mut self, _user: &mut AsynUser, _cmd: u8) -> AsynResult<()> {
2102        Err(AsynError::Status {
2103            status: AsynStatus::Error,
2104            message: "port has no asynGpib interface".into(),
2105        })
2106    }
2107
2108    /// C `asynGpib::addressedCmd` — send an addressed-command frame with ATN
2109    /// asserted (asynGpib.c:472-478, `vxiAddressedCmd` drvVxi11.c:1360-1404).
2110    /// The frame is built by [`crate::interfaces::gpib::addressed_request`].
2111    fn gpib_addressed_cmd(&mut self, _user: &mut AsynUser, _data: &[u8]) -> AsynResult<()> {
2112        Err(AsynError::Status {
2113            status: AsynStatus::Error,
2114            message: "port has no asynGpib interface".into(),
2115        })
2116    }
2117
2118    /// C `asynGpib::ifc` — assert Interface Clear (asynGpib.c:486-490).
2119    fn gpib_ifc(&mut self, _user: &mut AsynUser) -> AsynResult<()> {
2120        Err(AsynError::Status {
2121            status: AsynStatus::Error,
2122            message: "port has no asynGpib interface".into(),
2123        })
2124    }
2125
2126    /// C `asynGpib::ren` — set the Remote Enable line (asynGpib.c:492-496).
2127    fn gpib_ren(&mut self, _user: &mut AsynUser, _enable: bool) -> AsynResult<()> {
2128        Err(AsynError::Status {
2129            status: AsynStatus::Error,
2130            message: "port has no asynGpib interface".into(),
2131        })
2132    }
2133
2134    // --- Lifecycle ---
2135
2136    /// Called when the port is being shut down. Drivers override this
2137    /// to release hardware resources. Matches C asynPortDriver::shutdownPortDriver().
2138    fn shutdown(&mut self) -> AsynResult<()> {
2139        Ok(())
2140    }
2141
2142    // --- drvUser ---
2143
2144    /// Resolve a record's bind request ([`DrvUserRequest`]: drvInfo string, asyn
2145    /// `addr`, and the record's asyn interface) to a [`DrvUserInfo`] — the
2146    /// asyn-rs analogue of C `drvUserCreate`.
2147    ///
2148    /// Takes `&mut self` so a driver can register a parameter on demand from the
2149    /// resolved drvInfo (C Autoparam lazy creation) rather than requiring it be
2150    /// declared up front. Such a driver must create the parameter with the type
2151    /// the record will read it as — [`DrvUserRequest::iface`] — the way C
2152    /// `adsAsynPortDriver::getRecordInfoFromDrvInfo` derives the parameter's
2153    /// asyn type from the bound record's DTYP.
2154    ///
2155    /// [`DrvUserRequest::addr`] lets a multi-device driver reject an
2156    /// out-of-range address at bind time (C `drvUserCreate` runs `checkOffset`,
2157    /// drvModbusAsyn.cpp:378-384) instead of alarming on every I/O.
2158    ///
2159    /// Default: look up the shared reason by parameter name; ignore the rest.
2160    fn drv_user_create(&mut self, req: &DrvUserRequest) -> AsynResult<DrvUserInfo> {
2161        let reason = self
2162            .base()
2163            .params
2164            .find_param(&req.drv_info)
2165            .ok_or_else(|| AsynError::ParamNotFound(req.drv_info.clone()))?;
2166        Ok(DrvUserInfo::from_reason(reason))
2167    }
2168
2169    // --- Capabilities ---
2170
2171    /// Declare the capabilities this driver supports.
2172    /// Default implementation includes all scalar read/write operations.
2173    fn capabilities(&self) -> Vec<crate::interfaces::Capability> {
2174        crate::interfaces::default_capabilities()
2175    }
2176
2177    /// Check if this driver supports a specific capability.
2178    fn supports(&self, cap: crate::interfaces::Capability) -> bool {
2179        self.capabilities().contains(&cap)
2180    }
2181
2182    fn init(&mut self) -> AsynResult<()> {
2183        Ok(())
2184    }
2185}
2186
2187/// The driver sitting at the **bottom** of a port's octet interpose chain — C's
2188/// driver-registered `asynOctet` interface, the one `interposeInterface` keeps
2189/// as `pPrev` when it pushes a layer on top (asynManager.c:2190-2220).
2190///
2191/// A driver never knows it is being interposed in C: `findInterface` hands the
2192/// caller the topmost layer, and the layer calls down. The Rust equivalent of
2193/// "the caller" is the port actor, so the chain runs there
2194/// ([`octet_read_chain`] and friends) and the driver's `io_*_octet` are the raw
2195/// device transfer, nothing more.
2196struct DriverOctetLink<'a> {
2197    driver: &'a mut dyn PortDriver,
2198}
2199
2200impl OctetNext for DriverOctetLink<'_> {
2201    /// C `asynOctetBase::readIt` (asynOctetBase.c:224-238): call the driver's
2202    /// own `read`, and on success fan the result out to the port's octet
2203    /// interrupt users.
2204    ///
2205    /// This is *below* the EOS layer by construction, and that is the whole
2206    /// point. C interposes `octetBase` directly on the driver
2207    /// (asynOctetBase.c:156-159) and only then pushes `asynInterposeEos` on top
2208    /// of it (:169-171), so the stack is `EOS → octetBase → driver`: every
2209    /// interrupt user sees the RAW DRIVER CHUNK — terminator included, with the
2210    /// driver's own CNT/END eomReason — and gets one callback per lower-level
2211    /// read, not one per EOS-completed message. Firing above the chain (as the
2212    /// port actor used to) handed an I/O-Intr record `"abc"`/EOMR=EOS where C
2213    /// hands it `"abc\r\n"`/EOMR=CNT|END.
2214    fn read(&mut self, user: &AsynUser, buf: &mut [u8]) -> AsynResult<OctetReadResult> {
2215        let (nbytes_transferred, eom_reason) = self.driver.io_read_octet_eom(user, buf)?;
2216        if self.driver.base().octet_interrupt_process {
2217            let raw = &buf[..nbytes_transferred];
2218            // C's rule for a device read: `addr` decides, `reason` is never
2219            // consulted (asynOctetBase.c:203-215). `reason` still rides on the
2220            // value — C leaves `pasynUser->reason` on the callback's user — but
2221            // it selects nobody.
2222            self.driver.base().interrupts.notify_octet(
2223                OctetFanOut::ByAddr(user.addr),
2224                InterruptValue {
2225                    reason: user.reason,
2226                    addr: user.addr,
2227                    value: ParamValue::Octet(String::from_utf8_lossy(raw).into_owned()),
2228                    timestamp: SystemTime::now(),
2229                    iface: Some(InterfaceType::Octet),
2230                    ..Default::default()
2231                },
2232            );
2233        }
2234        Ok(OctetReadResult {
2235            nbytes_transferred,
2236            eom_reason,
2237        })
2238    }
2239
2240    fn write(&mut self, user: &mut AsynUser, data: &[u8]) -> AsynResult<usize> {
2241        self.driver.io_write_octet(user, data)
2242    }
2243
2244    fn flush(&mut self, user: &mut AsynUser) -> AsynResult<()> {
2245        self.driver.io_flush(user)
2246    }
2247}
2248
2249/// Run `f` with the port's chain and the driver below it, both borrowed at once.
2250///
2251/// The chain lives inside the driver (`base.interpose_octet`) while the driver
2252/// is the chain's base, so the two are lifted apart for the duration of one
2253/// transfer and the chain is put straight back. The actor owns the driver and is
2254/// the only caller, so no other code can observe the port between the take and
2255/// the restore.
2256fn with_octet_chain<T>(
2257    driver: &mut dyn PortDriver,
2258    f: impl FnOnce(&mut OctetInterposeStack, &mut DriverOctetLink<'_>) -> T,
2259) -> T {
2260    let multi_device = driver.base().flags.multi_device;
2261    let mut chain = std::mem::replace(
2262        &mut driver.base_mut().interpose_octet,
2263        OctetInterposeStack::new(multi_device),
2264    );
2265    let result = {
2266        let mut link = DriverOctetLink {
2267            driver: &mut *driver,
2268        };
2269        f(&mut chain, &mut link)
2270    };
2271    driver.base_mut().interpose_octet = chain;
2272    result
2273}
2274
2275/// One octet read on the port: through every interpose layer installed on the
2276/// addressed device, ending at the driver.
2277///
2278/// C `asynOctet::read` on the interface `findInterface` resolves — which is the
2279/// outermost interpose whenever one is installed. The chain belongs to the
2280/// **port**, not to the driver: `asynInterposeEos` / `asynInterposeEcho` /
2281/// `asynInterposeDelay` are pushed by the manager (asynManager.c:2190-2220) and
2282/// the driver below is not consulted and cannot opt out. Dispatching inside each
2283/// driver instead — as this crate did — made the chain per-driver opt-in, so the
2284/// EOS layer `drvAsynFTDIPortConfigure` installs (drvAsynFTDIPort.cpp:622-623,
2285/// ftdi.rs) was never run by anything.
2286pub(crate) fn octet_read_chain(
2287    driver: &mut dyn PortDriver,
2288    user: &AsynUser,
2289    buf: &mut [u8],
2290) -> AsynResult<(usize, EomReason)> {
2291    with_octet_chain(driver, |chain, link| chain.dispatch_read(user, buf, link))
2292        .map(|r| (r.nbytes_transferred, r.eom_reason))
2293}
2294
2295/// One octet write on the port, through the addressed device's chain
2296/// (see [`octet_read_chain`]).
2297pub(crate) fn octet_write_chain(
2298    driver: &mut dyn PortDriver,
2299    user: &mut AsynUser,
2300    data: &[u8],
2301) -> AsynResult<usize> {
2302    with_octet_chain(driver, |chain, link| chain.dispatch_write(user, data, link))
2303}
2304
2305/// One octet flush on the port, through the addressed device's chain — C
2306/// `asynInterposeEos::flushIt` resets the layer's read-ahead buffer and then
2307/// flushes the layer below (asynInterposeEos.c:259-274), which only happens if
2308/// the flush enters the chain at the top (see [`octet_read_chain`]).
2309pub(crate) fn octet_flush_chain(
2310    driver: &mut dyn PortDriver,
2311    user: &mut AsynUser,
2312) -> AsynResult<()> {
2313    with_octet_chain(driver, |chain, link| chain.dispatch_flush(user, link))
2314}
2315
2316#[cfg(test)]
2317mod tests {
2318    use super::*;
2319
2320    /// A driver whose `io_read_octet_eom` hands back one scripted chunk per
2321    /// call, with the driver-level eomReason C would report (CNT when the
2322    /// caller's buffer filled, END never — a stream driver has no message
2323    /// boundary). The EOS layer above it is what turns chunks into messages.
2324    struct ChunkDriver {
2325        base: PortDriverBase,
2326        chunks: Vec<Vec<u8>>,
2327        next: usize,
2328    }
2329
2330    impl ChunkDriver {
2331        fn new(chunks: Vec<&[u8]>) -> Self {
2332            let mut base = PortDriverBase::new("chunk", 1, PortFlags::default());
2333            base.octet_interrupt_process = true;
2334            base.init_connected(true);
2335            Self {
2336                base,
2337                chunks: chunks.into_iter().map(|c| c.to_vec()).collect(),
2338                next: 0,
2339            }
2340        }
2341    }
2342
2343    impl PortDriver for ChunkDriver {
2344        fn base(&self) -> &PortDriverBase {
2345            &self.base
2346        }
2347        fn base_mut(&mut self) -> &mut PortDriverBase {
2348            &mut self.base
2349        }
2350        fn io_read_octet_eom(
2351            &mut self,
2352            _user: &AsynUser,
2353            buf: &mut [u8],
2354        ) -> AsynResult<(usize, EomReason)> {
2355            let chunk = self.chunks.get(self.next).cloned().unwrap_or_default();
2356            self.next += 1;
2357            let n = chunk.len().min(buf.len());
2358            buf[..n].copy_from_slice(&chunk[..n]);
2359            let eom = if n == buf.len() {
2360                EomReason::CNT
2361            } else {
2362                EomReason::empty()
2363            };
2364            Ok((n, eom))
2365        }
2366    }
2367
2368    /// The octet interrupt fan-out runs BELOW the interpose chain, at the
2369    /// driver link — C's `asynOctetBase::readIt` (asynOctetBase.c:224-238),
2370    /// which is interposed directly on the driver (:156-159) with the EOS layer
2371    /// pushed on top of it (:169-171).
2372    ///
2373    /// Two boundaries, both invisible when the fan-out ran above the chain:
2374    /// (1) the payload an interrupt user receives is the RAW driver chunk —
2375    /// terminator included — not the EOS-stripped message the caller gets; and
2376    /// (2) a message assembled from two lower-level reads fires TWO callbacks,
2377    /// one per driver read, not one per completed message.
2378    #[test]
2379    fn octet_interrupt_fans_out_below_the_interpose_chain() {
2380        use crate::interpose::eos::EosInterpose;
2381        use crate::interrupt::{InterruptFilter, InterruptValue};
2382        use std::sync::{Arc, Mutex};
2383
2384        // The message "abc\r\n" arrives split across two driver reads.
2385        let mut drv = ChunkDriver::new(vec![b"ab", b"c\r\n"]);
2386        drv.base_mut()
2387            .install_octet_interpose(Box::new(EosInterpose::default()));
2388        drv.set_input_eos(&AsynUser::default(), b"\r\n").unwrap();
2389
2390        let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
2391        let seen_cb = seen.clone();
2392        let _sub = drv.base().interrupts.register_sync_callback(
2393            InterruptFilter::default(),
2394            move |iv: &InterruptValue| {
2395                if let ParamValue::Octet(s) = &iv.value {
2396                    seen_cb.lock().unwrap().push(s.clone());
2397                }
2398            },
2399        );
2400
2401        let user = AsynUser::default();
2402        let mut buf = [0u8; 32];
2403        let (n, eom) = octet_read_chain(&mut drv, &user, &mut buf).unwrap();
2404
2405        // The CALLER gets the EOS-terminated message, terminator stripped.
2406        assert_eq!(&buf[..n], b"abc");
2407        assert!(eom.contains(EomReason::EOS), "caller sees EOS, got {eom:?}");
2408
2409        // The INTERRUPT USERS get the raw driver chunks, terminator included,
2410        // one callback per lower-level read.
2411        assert_eq!(
2412            *seen.lock().unwrap(),
2413            vec!["ab".to_string(), "c\r\n".to_string()],
2414            "each driver read fans out its raw chunk"
2415        );
2416    }
2417
2418    struct TestDriver {
2419        base: PortDriverBase,
2420    }
2421
2422    impl TestDriver {
2423        fn new() -> Self {
2424            let mut base = PortDriverBase::new("test", 1, PortFlags::default());
2425            base.create_param("VAL", ParamType::Int32).unwrap();
2426            base.create_param("TEMP", ParamType::Float64).unwrap();
2427            base.create_param("MSG", ParamType::Octet).unwrap();
2428            base.create_param("BITS", ParamType::UInt32Digital).unwrap();
2429            Self { base }
2430        }
2431    }
2432
2433    impl PortDriver for TestDriver {
2434        fn base(&self) -> &PortDriverBase {
2435            &self.base
2436        }
2437        fn base_mut(&mut self) -> &mut PortDriverBase {
2438            &mut self.base
2439        }
2440    }
2441
2442    #[test]
2443    fn test_default_read_write_int32() {
2444        let mut drv = TestDriver::new();
2445        let mut user = AsynUser::new(0);
2446        drv.write_int32(&mut user, 42).unwrap();
2447        let user = AsynUser::new(0);
2448        assert_eq!(drv.read_int32(&user).unwrap(), 42);
2449    }
2450
2451    #[test]
2452    fn test_default_read_write_float64() {
2453        let mut drv = TestDriver::new();
2454        let mut user = AsynUser::new(1);
2455        drv.write_float64(&mut user, 3.14).unwrap();
2456        let user = AsynUser::new(1);
2457        assert!((drv.read_float64(&user).unwrap() - 3.14).abs() < 1e-10);
2458    }
2459
2460    #[test]
2461    fn test_default_read_write_octet() {
2462        let mut drv = TestDriver::new();
2463        let mut user = AsynUser::new(2);
2464        drv.write_octet(&mut user, b"hello").unwrap();
2465        let user = AsynUser::new(2);
2466        let mut buf = [0u8; 32];
2467        let n = drv.read_octet(&user, &mut buf).unwrap();
2468        assert_eq!(&buf[..n], b"hello");
2469    }
2470
2471    #[test]
2472    fn test_default_read_write_uint32() {
2473        let mut drv = TestDriver::new();
2474        let mut user = AsynUser::new(3);
2475        drv.write_uint32_digital(&mut user, 0xFF, 0x0F).unwrap();
2476        let user = AsynUser::new(3);
2477        assert_eq!(drv.read_uint32_digital(&user, 0xFF).unwrap(), 0x0F);
2478    }
2479
2480    #[test]
2481    fn test_connect_disconnect() {
2482        let mut drv = TestDriver::new();
2483        let user = AsynUser::default();
2484        assert!(drv.base().is_connected());
2485        drv.disconnect(&user).unwrap();
2486        assert!(!drv.base().is_connected());
2487        drv.connect(&user).unwrap();
2488        assert!(drv.base().is_connected());
2489    }
2490
2491    #[test]
2492    fn test_drv_user_create() {
2493        let mut drv = TestDriver::new();
2494        assert_eq!(
2495            drv.drv_user_create(&DrvUserRequest::new("VAL", 0))
2496                .unwrap()
2497                .reason,
2498            0
2499        );
2500        assert_eq!(
2501            drv.drv_user_create(&DrvUserRequest::new("TEMP", 0))
2502                .unwrap()
2503                .reason,
2504            1
2505        );
2506        assert!(
2507            drv.drv_user_create(&DrvUserRequest::new("NOPE", 0))
2508                .is_err()
2509        );
2510    }
2511
2512    #[test]
2513    fn test_call_param_callbacks() {
2514        let mut drv = TestDriver::new();
2515        let mut rx = drv.base_mut().interrupts.subscribe_async();
2516
2517        drv.base_mut().set_int32_param(0, 0, 100).unwrap();
2518        drv.base_mut().set_float64_param(1, 0, 2.0).unwrap();
2519        drv.base_mut().call_param_callbacks(0).unwrap();
2520
2521        let v1 = rx.try_recv().unwrap();
2522        assert_eq!(v1.reason, 0);
2523        let v2 = rx.try_recv().unwrap();
2524        assert_eq!(v2.reason, 1);
2525        assert!(rx.try_recv().is_err());
2526    }
2527
2528    #[test]
2529    fn flush_skips_undefined_scalar_but_keeps_array_trigger() {
2530        // C asynPortDriver.cpp:845 — a status/alarm change (or bare
2531        // mark_changed) on a never-set scalar consumes the changed flag
2532        // but fires no callback. Array/generic-pointer triggers have no
2533        // callCallbacks analog (:846-865 switch is scalar-only) and must
2534        // still fire even while undefined.
2535        let mut drv = TestDriver::new();
2536        let arr = drv
2537            .base_mut()
2538            .create_param("ARR", ParamType::Int32Array)
2539            .unwrap();
2540        let mut rx = drv.base_mut().interrupts.subscribe_async();
2541
2542        // Status change on the never-set scalar VAL (index 0): marks it
2543        // changed but it stays undefined.
2544        drv.base_mut()
2545            .params
2546            .set_param_status(0, 0, AsynStatus::Error, 0, 0)
2547            .unwrap();
2548        // Mark the never-set array param changed: an override-served trigger.
2549        drv.base_mut().mark_param_changed(arr, 0).unwrap();
2550
2551        drv.base_mut().call_param_callbacks(0).unwrap();
2552
2553        // Only the array trigger is delivered; the undefined scalar is gated.
2554        let iv = rx.try_recv().unwrap();
2555        assert_eq!(
2556            iv.reason, arr,
2557            "array trigger must still fire while undefined"
2558        );
2559        assert!(
2560            rx.try_recv().is_err(),
2561            "undefined scalar must not emit an I/O Intr"
2562        );
2563
2564        // Once the scalar is defined, a subsequent change does fire.
2565        drv.base_mut().set_int32_param(0, 0, 7).unwrap();
2566        drv.base_mut().call_param_callbacks(0).unwrap();
2567        let iv2 = rx.try_recv().unwrap();
2568        assert_eq!(iv2.reason, 0, "defined scalar must fire");
2569    }
2570
2571    #[test]
2572    fn uint32_callback_mask_does_not_leak_across_flushes() {
2573        // C resets uInt32CallbackMask = 0 after each uint32Callback
2574        // (asynPortDriver.cpp:855): a second flush must deliver only the
2575        // bits changed since the first, never the accumulated history.
2576        let mut drv = TestDriver::new();
2577        let mut rx = drv.base_mut().interrupts.subscribe_async();
2578
2579        // flush 1: change bit 0 on BITS (param index 3).
2580        drv.base_mut()
2581            .params
2582            .set_uint32(3, 0, 0x01, 0x01, 0)
2583            .unwrap();
2584        drv.base_mut().call_param_callbacks(0).unwrap();
2585        let iv1 = rx.try_recv().unwrap();
2586        assert_eq!(iv1.reason, 3);
2587        assert_eq!(iv1.uint32_changed_mask, 0x01);
2588
2589        // flush 2: change bit 1 only — must deliver 0x02, not 0x03.
2590        drv.base_mut()
2591            .params
2592            .set_uint32(3, 0, 0x02, 0x02, 0)
2593            .unwrap();
2594        drv.base_mut().call_param_callbacks(0).unwrap();
2595        let iv2 = rx.try_recv().unwrap();
2596        assert_eq!(
2597            iv2.uint32_changed_mask, 0x02,
2598            "second flush must not leak flush-1 bits via an un-reset mask"
2599        );
2600        assert_eq!(
2601            drv.base().params.get_uint32_interrupt_mask(3, 0).unwrap(),
2602            0,
2603            "the flush must consume (reset) the callback mask"
2604        );
2605    }
2606
2607    #[test]
2608    fn test_call_param_callbacks_propagates_aux_status_and_alarm() {
2609        // C parity: asynPortDriver.cpp:631-642 writes the param's stored
2610        // status / alarmStatus / alarmSeverity onto the subscriber's
2611        // pasynUser before invoking the callback. The Rust port carries
2612        // those fields on InterruptValue.
2613        let mut drv = TestDriver::new();
2614        let mut rx = drv.base_mut().interrupts.subscribe_async();
2615
2616        drv.base_mut().set_int32_param(0, 0, 99).unwrap();
2617        drv.base_mut()
2618            .params
2619            .set_param_status(0, 0, crate::error::AsynStatus::Timeout, 4, 2)
2620            .unwrap();
2621        drv.base_mut().call_param_callbacks(0).unwrap();
2622
2623        let iv = rx.try_recv().unwrap();
2624        assert_eq!(iv.reason, 0);
2625        assert!(matches!(iv.aux_status, crate::error::AsynStatus::Timeout));
2626        assert_eq!(iv.alarm_status, 4);
2627        assert_eq!(iv.alarm_severity, 2);
2628    }
2629
2630    #[test]
2631    fn test_call_param_callback_single_propagates_aux_status() {
2632        // Mirror for the single-flush path (call_param_callback).
2633        let mut drv = TestDriver::new();
2634        let mut rx = drv.base_mut().interrupts.subscribe_async();
2635
2636        drv.base_mut().set_int32_param(0, 0, 1).unwrap();
2637        drv.base_mut()
2638            .params
2639            .set_param_status(0, 0, crate::error::AsynStatus::Disconnected, 7, 3)
2640            .unwrap();
2641        drv.base_mut().call_param_callback(0, 0).unwrap();
2642
2643        let iv = rx.try_recv().unwrap();
2644        assert!(matches!(
2645            iv.aux_status,
2646            crate::error::AsynStatus::Disconnected
2647        ));
2648        assert_eq!(iv.alarm_status, 7);
2649        assert_eq!(iv.alarm_severity, 3);
2650    }
2651
2652    #[test]
2653    fn test_no_callback_for_unchanged() {
2654        let mut drv = TestDriver::new();
2655        let mut rx = drv.base_mut().interrupts.subscribe_async();
2656
2657        drv.base_mut().set_int32_param(0, 0, 5).unwrap();
2658        drv.base_mut().call_param_callbacks(0).unwrap();
2659        let _ = rx.try_recv().unwrap(); // consume
2660
2661        // Set same value — no interrupt
2662        drv.base_mut().set_int32_param(0, 0, 5).unwrap();
2663        drv.base_mut().call_param_callbacks(0).unwrap();
2664        assert!(rx.try_recv().is_err());
2665    }
2666
2667    #[test]
2668    fn test_array_not_supported_by_default() {
2669        let mut drv = TestDriver::new();
2670        let user = AsynUser::new(0);
2671        let mut buf = [0f64; 10];
2672        assert!(drv.read_float64_array(&user, &mut buf).is_err());
2673        assert!(drv.write_float64_array(&user, &[1.0]).is_err());
2674    }
2675
2676    #[test]
2677    fn test_option_set_get() {
2678        let mut drv = TestDriver::new();
2679        drv.set_option(&mut AsynUser::default(), "baud", "9600")
2680            .unwrap();
2681        assert_eq!(drv.get_option("baud").unwrap(), "9600");
2682        drv.set_option(&mut AsynUser::default(), "baud", "115200")
2683            .unwrap();
2684        assert_eq!(drv.get_option("baud").unwrap(), "115200");
2685    }
2686
2687    #[test]
2688    fn test_option_not_found() {
2689        let drv = TestDriver::new();
2690        let err = drv.get_option("nonexistent").unwrap_err();
2691        assert!(matches!(err, AsynError::OptionNotFound(_)));
2692    }
2693
2694    #[test]
2695    fn test_report_no_panic() {
2696        let mut drv = TestDriver::new();
2697        drv.set_option(&mut AsynUser::default(), "testkey", "testval")
2698            .unwrap();
2699        drv.base_mut().set_int32_param(0, 0, 42).unwrap();
2700        for level in 0..=3 {
2701            let mut out = String::new();
2702            drv.report(&mut out, level);
2703        }
2704    }
2705
2706    #[test]
2707    fn test_callback_uses_param_timestamp() {
2708        let mut drv = TestDriver::new();
2709        let mut rx = drv.base_mut().interrupts.subscribe_async();
2710
2711        let custom_ts = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
2712        drv.base_mut().set_int32_param(0, 0, 77).unwrap();
2713        drv.base_mut().set_param_timestamp(0, 0, custom_ts).unwrap();
2714        drv.base_mut().call_param_callbacks(0).unwrap();
2715
2716        let v = rx.try_recv().unwrap();
2717        assert_eq!(v.reason, 0);
2718        assert_eq!(v.timestamp, custom_ts);
2719    }
2720
2721    #[test]
2722    fn test_default_read_write_enum() {
2723        use crate::param::EnumEntry;
2724
2725        let mut base = PortDriverBase::new("test_enum", 1, PortFlags::default());
2726        base.create_param("MODE", ParamType::Enum).unwrap();
2727
2728        struct EnumDriver {
2729            base: PortDriverBase,
2730        }
2731        impl PortDriver for EnumDriver {
2732            fn base(&self) -> &PortDriverBase {
2733                &self.base
2734            }
2735            fn base_mut(&mut self) -> &mut PortDriverBase {
2736                &mut self.base
2737            }
2738        }
2739
2740        let mut drv = EnumDriver { base };
2741        let choices: Arc<[EnumEntry]> = Arc::from(vec![
2742            EnumEntry {
2743                string: "Off".into(),
2744                value: 0,
2745                severity: 0,
2746            },
2747            EnumEntry {
2748                string: "On".into(),
2749                value: 1,
2750                severity: 0,
2751            },
2752        ]);
2753        let mut user = AsynUser::new(0);
2754        drv.write_enum_choices(&mut user, choices).unwrap();
2755        drv.write_enum(&mut user, 1).unwrap();
2756        let (idx, ch) = drv.read_enum(&AsynUser::new(0)).unwrap();
2757        assert_eq!(idx, 1);
2758        assert_eq!(ch[1].string, "On");
2759    }
2760
2761    #[test]
2762    fn test_enum_callback() {
2763        use crate::param::{EnumEntry, ParamValue};
2764
2765        let mut base = PortDriverBase::new("test_enum_cb", 1, PortFlags::default());
2766        base.create_param("MODE", ParamType::Enum).unwrap();
2767        let mut rx = base.interrupts.subscribe_async();
2768
2769        struct EnumDriver {
2770            base: PortDriverBase,
2771        }
2772        impl PortDriver for EnumDriver {
2773            fn base(&self) -> &PortDriverBase {
2774                &self.base
2775            }
2776            fn base_mut(&mut self) -> &mut PortDriverBase {
2777                &mut self.base
2778            }
2779        }
2780
2781        let mut drv = EnumDriver { base };
2782        let choices: Arc<[EnumEntry]> = Arc::from(vec![
2783            EnumEntry {
2784                string: "A".into(),
2785                value: 0,
2786                severity: 0,
2787            },
2788            EnumEntry {
2789                string: "B".into(),
2790                value: 1,
2791                severity: 0,
2792            },
2793        ]);
2794        drv.base_mut()
2795            .set_enum_choices_param(0, 0, choices)
2796            .unwrap();
2797        drv.base_mut().set_enum_index_param(0, 0, 1).unwrap();
2798        drv.base_mut().call_param_callbacks(0).unwrap();
2799
2800        let v = rx.try_recv().unwrap();
2801        assert_eq!(v.reason, 0);
2802        assert!(matches!(v.value, ParamValue::Enum { index: 1, .. }));
2803    }
2804
2805    #[test]
2806    fn test_default_read_write_generic_pointer() {
2807        let mut base = PortDriverBase::new("test_gp", 1, PortFlags::default());
2808        base.create_param("PTR", ParamType::GenericPointer).unwrap();
2809
2810        struct GpDriver {
2811            base: PortDriverBase,
2812        }
2813        impl PortDriver for GpDriver {
2814            fn base(&self) -> &PortDriverBase {
2815                &self.base
2816            }
2817            fn base_mut(&mut self) -> &mut PortDriverBase {
2818                &mut self.base
2819            }
2820        }
2821
2822        let mut drv = GpDriver { base };
2823        let data: Arc<dyn std::any::Any + Send + Sync> = Arc::new(99i32);
2824        let mut user = AsynUser::new(0);
2825        drv.write_generic_pointer(&mut user, data).unwrap();
2826        let val = drv.read_generic_pointer(&AsynUser::new(0)).unwrap();
2827        assert_eq!(*val.downcast_ref::<i32>().unwrap(), 99);
2828    }
2829
2830    #[test]
2831    fn test_generic_pointer_callback() {
2832        use crate::param::ParamValue;
2833
2834        let mut base = PortDriverBase::new("test_gp_cb", 1, PortFlags::default());
2835        base.create_param("PTR", ParamType::GenericPointer).unwrap();
2836        let mut rx = base.interrupts.subscribe_async();
2837
2838        struct GpDriver {
2839            base: PortDriverBase,
2840        }
2841        impl PortDriver for GpDriver {
2842            fn base(&self) -> &PortDriverBase {
2843                &self.base
2844            }
2845            fn base_mut(&mut self) -> &mut PortDriverBase {
2846                &mut self.base
2847            }
2848        }
2849
2850        let mut drv = GpDriver { base };
2851        let data: Arc<dyn std::any::Any + Send + Sync> = Arc::new(vec![1, 2, 3]);
2852        drv.base_mut()
2853            .set_generic_pointer_param(0, 0, data)
2854            .unwrap();
2855        drv.base_mut().call_param_callbacks(0).unwrap();
2856
2857        let v = rx.try_recv().unwrap();
2858        assert_eq!(v.reason, 0);
2859        assert!(matches!(v.value, ParamValue::GenericPointer(_)));
2860    }
2861
2862    #[test]
2863    fn test_interpose_push_requires_lock() {
2864        use crate::interpose::{OctetInterpose, OctetNext, OctetReadResult};
2865        use parking_lot::Mutex;
2866        use std::sync::Arc;
2867
2868        struct NoopInterpose;
2869        impl OctetInterpose for NoopInterpose {
2870            fn read(
2871                &mut self,
2872                user: &AsynUser,
2873                buf: &mut [u8],
2874                next: &mut dyn OctetNext,
2875            ) -> AsynResult<OctetReadResult> {
2876                next.read(user, buf)
2877            }
2878            fn write(
2879                &mut self,
2880                user: &mut AsynUser,
2881                data: &[u8],
2882                next: &mut dyn OctetNext,
2883            ) -> AsynResult<usize> {
2884                next.write(user, data)
2885            }
2886            fn flush(&mut self, user: &mut AsynUser, next: &mut dyn OctetNext) -> AsynResult<()> {
2887                next.flush(user)
2888            }
2889        }
2890
2891        let port: Arc<Mutex<dyn PortDriver>> = Arc::new(Mutex::new(TestDriver::new()));
2892
2893        {
2894            let mut guard = port.lock();
2895            guard
2896                .base_mut()
2897                .install_octet_interpose(Box::new(NoopInterpose));
2898            assert_eq!(guard.base().interpose_octet.len(), 1);
2899        }
2900    }
2901
2902    /// The `set_input_eos` write owner must forward the terminator to an
2903    /// installed `EosInterpose`, not just cache it in `base.input_eos` —
2904    /// otherwise a runtime IEOS change never terminates reads (the F7 gap).
2905    #[test]
2906    fn test_set_input_eos_reaches_installed_interpose() {
2907        use crate::interpose::eos::EosInterpose;
2908        use crate::interpose::{EomReason, OctetNext, OctetReadResult};
2909
2910        struct RawSource {
2911            data: Vec<u8>,
2912            pos: usize,
2913        }
2914        impl OctetNext for RawSource {
2915            fn read(&mut self, _u: &AsynUser, buf: &mut [u8]) -> AsynResult<OctetReadResult> {
2916                let avail = self.data.len() - self.pos;
2917                let n = avail.min(buf.len());
2918                buf[..n].copy_from_slice(&self.data[self.pos..self.pos + n]);
2919                self.pos += n;
2920                Ok(OctetReadResult {
2921                    nbytes_transferred: n,
2922                    eom_reason: EomReason::CNT,
2923                })
2924            }
2925            fn write(&mut self, _u: &mut AsynUser, data: &[u8]) -> AsynResult<usize> {
2926                Ok(data.len())
2927            }
2928            fn flush(&mut self, _u: &mut AsynUser) -> AsynResult<()> {
2929                Ok(())
2930            }
2931        }
2932
2933        let mut drv = TestDriver::new();
2934        drv.base_mut()
2935            .install_octet_interpose(Box::new(EosInterpose::default()));
2936
2937        // Set IEOS through the driver trait: caches in base AND must reach
2938        // the interpose.
2939        drv.set_input_eos(&AsynUser::default(), b"\n").unwrap();
2940        assert_eq!(drv.base().input_eos(0), b"\n");
2941
2942        let user = AsynUser::default();
2943        // "ab\n" exactly: the EOS read returns "ab" and leaves no read-ahead
2944        // in the interpose buffer, so the cleared-EOS read below genuinely
2945        // reads the next source fresh.
2946        let mut src = RawSource {
2947            data: b"ab\n".to_vec(),
2948            pos: 0,
2949        };
2950        let mut buf = [0u8; 16];
2951        let r = drv
2952            .base_mut()
2953            .interpose_octet
2954            .dispatch_read(&user, &mut buf, &mut src)
2955            .unwrap();
2956        assert_eq!(&buf[..r.nbytes_transferred], b"ab");
2957        assert!(r.eom_reason.contains(EomReason::EOS));
2958
2959        // Clearing IEOS (binary-suppress path) must also reach the interpose:
2960        // the read then passes through with no EOS termination.
2961        drv.set_input_eos(&AsynUser::default(), b"").unwrap();
2962        assert_eq!(drv.base().input_eos(0), b"");
2963        let mut src2 = RawSource {
2964            data: b"xy\nz".to_vec(),
2965            pos: 0,
2966        };
2967        let mut buf2 = [0u8; 16];
2968        let r2 = drv
2969            .base_mut()
2970            .interpose_octet
2971            .dispatch_read(&user, &mut buf2, &mut src2)
2972            .unwrap();
2973        assert_eq!(&buf2[..r2.nbytes_transferred], b"xy\nz");
2974        assert!(!r2.eom_reason.contains(EomReason::EOS));
2975    }
2976
2977    /// R14-49: the EOS hooks take the `asynUser`, so a multi-device port holds
2978    /// one terminator per device — C's `eosPvt` is created per (port, addr)
2979    /// (asynInterposeEos.c:84-120) and every hook takes the user that selects it
2980    /// (:288-296). A port-wide pair could not answer two devices.
2981    #[test]
2982    fn each_device_on_a_multi_device_port_holds_its_own_eos() {
2983        struct MultiDriver {
2984            base: PortDriverBase,
2985        }
2986        impl PortDriver for MultiDriver {
2987            fn base(&self) -> &PortDriverBase {
2988                &self.base
2989            }
2990            fn base_mut(&mut self) -> &mut PortDriverBase {
2991                &mut self.base
2992            }
2993        }
2994        let mut drv = MultiDriver {
2995            base: PortDriverBase::new(
2996                "eos_multi",
2997                4,
2998                PortFlags {
2999                    multi_device: true,
3000                    ..PortFlags::default()
3001                },
3002            ),
3003        };
3004
3005        let dev1 = AsynUser::default().with_addr(1);
3006        let dev2 = AsynUser::default().with_addr(2);
3007        drv.set_input_eos(&dev1, b"\n").unwrap();
3008        drv.set_output_eos(&dev1, b"\r\n").unwrap();
3009        drv.set_input_eos(&dev2, b";").unwrap();
3010
3011        assert_eq!(drv.get_input_eos(&dev1), b"\n");
3012        assert_eq!(drv.get_input_eos(&dev2), b";");
3013        assert_eq!(drv.get_output_eos(&dev1), b"\r\n");
3014        // A device that was never configured has no terminator — C's
3015        // zero-initialised `eosInLen`.
3016        assert!(
3017            drv.get_input_eos(&AsynUser::default().with_addr(3))
3018                .is_empty()
3019        );
3020        assert!(drv.get_output_eos(&dev2).is_empty());
3021    }
3022
3023    /// The other boundary: a port that never declared `ASYN_MULTIDEVICE` has no
3024    /// devices to key by — C's `findDpCommon` (asynManager.c:496-509) and
3025    /// `findInterface` resolve *every* addr to the port itself, so
3026    /// `asynSetEos(port, -1, ...)` and a record at ADDR 0 must reach the same
3027    /// terminator. Splitting them by raw addr would leave the record reading
3028    /// with no EOS at all.
3029    #[test]
3030    fn a_single_device_port_collapses_every_addr_onto_one_eos() {
3031        let mut drv = TestDriver::new();
3032        drv.set_input_eos(&AsynUser::default().with_addr(-1), b"\n")
3033            .unwrap();
3034        assert_eq!(drv.get_input_eos(&AsynUser::default().with_addr(0)), b"\n");
3035        assert_eq!(drv.get_input_eos(&AsynUser::default().with_addr(7)), b"\n");
3036    }
3037
3038    /// R6-46 owner path: `set_connected` is the single transition owner, so
3039    /// every driver that reconnects through it (serial, IP, prologix …) gets
3040    /// the interpose reset for free. C wires this as an exception callback
3041    /// (`asynInterposeEos.c:110,142-151`); here the owner drives the stack
3042    /// directly. Boundaries: both edges reset (C's `asynExceptionConnect`
3043    /// fires from `exceptionConnect` AND `exceptionDisconnect`), and a
3044    /// no-op call (same state) must not.
3045    #[test]
3046    fn set_connected_resets_interpose_link_state() {
3047        use crate::interpose::{OctetInterpose, OctetNext, OctetReadResult};
3048        use std::sync::Arc;
3049        use std::sync::atomic::{AtomicUsize, Ordering};
3050
3051        struct CountingInterpose(Arc<AtomicUsize>);
3052        impl OctetInterpose for CountingInterpose {
3053            fn read(
3054                &mut self,
3055                user: &AsynUser,
3056                buf: &mut [u8],
3057                next: &mut dyn OctetNext,
3058            ) -> AsynResult<OctetReadResult> {
3059                next.read(user, buf)
3060            }
3061            fn write(
3062                &mut self,
3063                user: &mut AsynUser,
3064                data: &[u8],
3065                next: &mut dyn OctetNext,
3066            ) -> AsynResult<usize> {
3067                next.write(user, data)
3068            }
3069            fn flush(&mut self, user: &mut AsynUser, next: &mut dyn OctetNext) -> AsynResult<()> {
3070                next.flush(user)
3071            }
3072            fn connection_changed(&mut self) {
3073                self.0.fetch_add(1, Ordering::Relaxed);
3074            }
3075        }
3076
3077        let resets = Arc::new(AtomicUsize::new(0));
3078        let mut base = PortDriverBase::new("reset_test", 1, PortFlags::default());
3079        base.install_octet_interpose(Box::new(CountingInterpose(resets.clone())));
3080
3081        // Port starts connected. Disconnect edge → reset (C exceptionDisconnect).
3082        assert!(base.set_connected(false));
3083        assert_eq!(resets.load(Ordering::Relaxed), 1);
3084
3085        // Redundant call, no state change → no fan-out, no reset.
3086        assert!(!base.set_connected(false));
3087        assert_eq!(resets.load(Ordering::Relaxed), 1);
3088
3089        // Reconnect edge → reset again (C exceptionConnect).
3090        assert!(base.set_connected(true));
3091        assert_eq!(resets.load(Ordering::Relaxed), 2);
3092    }
3093
3094    #[test]
3095    fn test_default_read_write_int64() {
3096        let mut base = PortDriverBase::new("test_i64", 1, PortFlags::default());
3097        base.create_param("BIG", ParamType::Int64).unwrap();
3098
3099        struct I64Driver {
3100            base: PortDriverBase,
3101        }
3102        impl PortDriver for I64Driver {
3103            fn base(&self) -> &PortDriverBase {
3104                &self.base
3105            }
3106            fn base_mut(&mut self) -> &mut PortDriverBase {
3107                &mut self.base
3108            }
3109        }
3110
3111        let mut drv = I64Driver { base };
3112        let mut user = AsynUser::new(0);
3113        drv.write_int64(&mut user, i64::MAX).unwrap();
3114        assert_eq!(drv.read_int64(&AsynUser::new(0)).unwrap(), i64::MAX);
3115    }
3116
3117    #[test]
3118    fn test_get_bounds_int64_default() {
3119        let base = PortDriverBase::new("test_bounds", 1, PortFlags::default());
3120        struct BoundsDriver {
3121            base: PortDriverBase,
3122        }
3123        impl PortDriver for BoundsDriver {
3124            fn base(&self) -> &PortDriverBase {
3125                &self.base
3126            }
3127            fn base_mut(&mut self) -> &mut PortDriverBase {
3128                &mut self.base
3129            }
3130        }
3131        let drv = BoundsDriver { base };
3132        let (lo, hi) = drv.get_bounds_int64(&AsynUser::default()).unwrap();
3133        // C asynInt64Base.c:99 default: *low = *high = 0 (so a driver
3134        // that does not implement getBounds skips LINEAR ESLO/EOFF).
3135        assert_eq!(lo, 0);
3136        assert_eq!(hi, 0);
3137    }
3138
3139    #[test]
3140    fn test_per_addr_device_state() {
3141        let mut base = PortDriverBase::new(
3142            "multi",
3143            4,
3144            PortFlags {
3145                multi_device: true,
3146                can_block: false,
3147                destructible: true,
3148            },
3149        );
3150        base.create_param("V", ParamType::Int32).unwrap();
3151
3152        // Default: all connected
3153        assert!(base.is_device_connected(0));
3154        assert!(base.is_device_connected(1));
3155
3156        // Disable addr 1
3157        base.device_state(1).enabled = false;
3158        assert!(base.check_ready_addr(0).is_ok());
3159        let err = base.check_ready_addr(1).unwrap_err();
3160        assert!(format!("{err}").contains("not enabled"));
3161
3162        // Disconnect addr 2
3163        base.device_state(2).connected = false;
3164        let err = base.check_ready_addr(2).unwrap_err();
3165        assert!(format!("{err}").contains("not connected"));
3166    }
3167
3168    #[test]
3169    fn test_per_addr_single_device_ignored() {
3170        let mut base = PortDriverBase::new("single", 1, PortFlags::default());
3171        base.create_param("V", ParamType::Int32).unwrap();
3172        // For single-device, per-addr check passes even if no device state
3173        assert!(base.check_ready_addr(0).is_ok());
3174    }
3175
3176    #[test]
3177    fn test_timestamp_source() {
3178        let mut base = PortDriverBase::new("ts_test", 1, PortFlags::default());
3179        base.create_param("V", ParamType::Int32).unwrap();
3180
3181        let fixed_ts = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(999999);
3182        base.register_timestamp_source(move || fixed_ts);
3183
3184        assert_eq!(base.current_timestamp(), fixed_ts);
3185    }
3186
3187    #[test]
3188    fn test_timestamp_source_in_callbacks() {
3189        let mut base = PortDriverBase::new("ts_cb", 1, PortFlags::default());
3190        base.create_param("V", ParamType::Int32).unwrap();
3191        let mut rx = base.interrupts.subscribe_async();
3192
3193        let fixed_ts = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(123456);
3194        base.register_timestamp_source(move || fixed_ts);
3195
3196        struct TsDriver {
3197            base: PortDriverBase,
3198        }
3199        impl PortDriver for TsDriver {
3200            fn base(&self) -> &PortDriverBase {
3201                &self.base
3202            }
3203            fn base_mut(&mut self) -> &mut PortDriverBase {
3204                &mut self.base
3205            }
3206        }
3207        let mut drv = TsDriver { base };
3208        drv.base_mut().set_int32_param(0, 0, 42).unwrap();
3209        drv.base_mut().call_param_callbacks(0).unwrap();
3210
3211        let v = rx.try_recv().unwrap();
3212        // Should use fixed_ts since no per-param timestamp is set
3213        assert_eq!(v.timestamp, fixed_ts);
3214    }
3215
3216    #[test]
3217    fn test_queue_priority_connect() {
3218        assert!(QueuePriority::Connect > QueuePriority::High);
3219    }
3220
3221    #[test]
3222    fn test_port_flags_destructible_default_is_opt_in() {
3223        // C asyn parity: ASYN_DESTRUCTIBLE (0x0004, asynDriver.h:97) is
3224        // a `registerPort` attribute that callers opt into. Default
3225        // must be false so drivers don't accidentally accept a
3226        // shutdownPort call. PortDriver authors that want shutdown
3227        // support set `destructible: true` explicitly.
3228        let flags = PortFlags::default();
3229        assert!(
3230            !flags.destructible,
3231            "destructible must be opt-in (C parity)"
3232        );
3233    }
3234
3235    #[test]
3236    fn shutdown_lifecycle_refuses_non_destructible() {
3237        let mut base = PortDriverBase::new(
3238            "p_nondestr",
3239            1,
3240            PortFlags {
3241                multi_device: false,
3242                can_block: false,
3243                destructible: false,
3244            },
3245        );
3246        match base.shutdown_lifecycle() {
3247            Err(AsynError::Status { message, .. }) => {
3248                assert!(message.contains("ASYN_DESTRUCTIBLE"), "msg={message}");
3249            }
3250            other => panic!("expected ASYN_DESTRUCTIBLE refusal, got {other:?}"),
3251        }
3252        assert!(
3253            !base.is_defunct(),
3254            "non-destructible port must not flip defunct"
3255        );
3256        assert!(base.is_enabled(), "non-destructible port must stay enabled");
3257    }
3258
3259    #[test]
3260    fn shutdown_lifecycle_marks_destructible_defunct_and_idempotent() {
3261        let mut base = PortDriverBase::new(
3262            "p_destr",
3263            1,
3264            PortFlags {
3265                multi_device: false,
3266                can_block: false,
3267                destructible: true,
3268            },
3269        );
3270        assert!(base.is_enabled());
3271        assert!(!base.is_defunct());
3272        base.shutdown_lifecycle().unwrap();
3273        assert!(
3274            !base.is_enabled(),
3275            "shutdown_lifecycle must flip enabled=false"
3276        );
3277        assert!(
3278            base.is_defunct(),
3279            "shutdown_lifecycle must flip defunct=true"
3280        );
3281        // Idempotent — second call is Ok and leaves state unchanged.
3282        base.shutdown_lifecycle().unwrap();
3283        assert!(base.is_defunct());
3284        // A shut-down port is refused by the queue gate as a *disabled* one:
3285        // C's `queueRequest` has no defunct branch (asynManager.c:1539-1552),
3286        // it only sees the `enabled=FALSE` that `shutdownPort` left behind
3287        // (:2282-2283). "port %s disabled" is the message an operator gets.
3288        match base.check_ready() {
3289            Err(AsynError::Status { status, message }) => {
3290                assert_eq!(status, AsynStatus::Disabled);
3291                assert_eq!(message, "port p_destr disabled");
3292            }
3293            other => panic!("expected the disabled refusal, got {other:?}"),
3294        }
3295        // The one place C names the shutdown is `enable` (:2236-2241).
3296        match base.set_enabled(true) {
3297            Err(AsynError::Status { status, message }) => {
3298                assert_eq!(status, AsynStatus::Disabled);
3299                assert_eq!(message, "asynManager:enable: port has been shut down");
3300            }
3301            other => panic!("expected the shut-down refusal, got {other:?}"),
3302        }
3303    }
3304
3305    // --- Phase 2B: per-addr connect/disconnect/enable/disable ---
3306
3307    #[test]
3308    fn test_connect_addr() {
3309        let mut base = PortDriverBase::new(
3310            "multi_conn",
3311            4,
3312            PortFlags {
3313                multi_device: true,
3314                can_block: false,
3315                destructible: true,
3316            },
3317        );
3318        base.create_param("V", ParamType::Int32).unwrap();
3319
3320        base.disconnect_addr(1);
3321        assert!(!base.is_device_connected(1));
3322        assert!(base.check_ready_addr(1).is_err());
3323
3324        base.connect_addr(1);
3325        assert!(base.is_device_connected(1));
3326        assert!(base.check_ready_addr(1).is_ok());
3327    }
3328
3329    #[test]
3330    fn test_enable_disable_addr() {
3331        let mut base = PortDriverBase::new(
3332            "multi_en",
3333            4,
3334            PortFlags {
3335                multi_device: true,
3336                can_block: false,
3337                destructible: true,
3338            },
3339        );
3340        base.create_param("V", ParamType::Int32).unwrap();
3341
3342        base.disable_addr(2);
3343        let err = base.check_ready_addr(2).unwrap_err();
3344        assert!(format!("{err}").contains("not enabled"));
3345
3346        base.enable_addr(2);
3347        assert!(base.check_ready_addr(2).is_ok());
3348    }
3349
3350    #[test]
3351    fn test_port_level_overrides_addr() {
3352        let mut base = PortDriverBase::new(
3353            "multi_override",
3354            4,
3355            PortFlags {
3356                multi_device: true,
3357                can_block: false,
3358                destructible: true,
3359            },
3360        );
3361        base.create_param("V", ParamType::Int32).unwrap();
3362
3363        // Port-level disabled overrides addr-level enabled
3364        base.enabled = false;
3365        base.enable_addr(0); // addr 0 is enabled, but port is disabled
3366        let err = base.check_ready_addr(0).unwrap_err();
3367        assert!(format!("{err}").contains("disabled"));
3368    }
3369
3370    #[test]
3371    fn test_per_addr_exception_announced() {
3372        use std::sync::atomic::{AtomicI32, Ordering};
3373
3374        let mut base = PortDriverBase::new(
3375            "multi_exc",
3376            4,
3377            PortFlags {
3378                multi_device: true,
3379                can_block: false,
3380                destructible: true,
3381            },
3382        );
3383        base.create_param("V", ParamType::Int32).unwrap();
3384
3385        let exc_mgr = Arc::new(crate::exception::ExceptionManager::new());
3386        base.bind_exception_sink(exc_mgr.clone());
3387
3388        let last_addr = Arc::new(AtomicI32::new(-99));
3389        let last_addr2 = last_addr.clone();
3390        exc_mgr.add_callback(move |event| {
3391            last_addr2.store(event.addr, Ordering::Relaxed);
3392        });
3393
3394        base.disconnect_addr(3);
3395        assert_eq!(last_addr.load(Ordering::Relaxed), 3);
3396
3397        base.enable_addr(2);
3398        assert_eq!(last_addr.load(Ordering::Relaxed), 2);
3399    }
3400
3401    /// C parity (asynManager.c:2151-2160 exceptionConnect,
3402    /// :2174-2185 exceptionDisconnect): redundant connect/disconnect
3403    /// on a port already in that state must NOT fan out a duplicate
3404    /// `asynExceptionConnect`. Subscribers depend on the event
3405    /// edge — duplicate fan-out causes them to e.g. re-subscribe or
3406    /// re-arm timers that should fire exactly once per transition.
3407    #[test]
3408    fn test_connect_disconnect_announce_only_on_transition() {
3409        use std::sync::atomic::{AtomicUsize, Ordering};
3410
3411        let mut base = PortDriverBase::new(
3412            "edge",
3413            4,
3414            PortFlags {
3415                multi_device: true,
3416                can_block: false,
3417                destructible: true,
3418            },
3419        );
3420        base.create_param("V", ParamType::Int32).unwrap();
3421        let exc_mgr = Arc::new(crate::exception::ExceptionManager::new());
3422        base.bind_exception_sink(exc_mgr.clone());
3423
3424        let connect_hits = Arc::new(AtomicUsize::new(0));
3425        let hits2 = connect_hits.clone();
3426        exc_mgr.add_callback(move |event| {
3427            if event.exception == AsynException::Connect {
3428                hits2.fetch_add(1, Ordering::Relaxed);
3429            }
3430        });
3431
3432        // device starts connected by DeviceState::default — a redundant
3433        // connect_addr is a no-op.
3434        base.connect_addr(2);
3435        assert_eq!(
3436            connect_hits.load(Ordering::Relaxed),
3437            0,
3438            "redundant connect_addr must not fan out"
3439        );
3440
3441        // First transition fires once.
3442        base.disconnect_addr(2);
3443        assert_eq!(connect_hits.load(Ordering::Relaxed), 1);
3444
3445        // Redundant disconnect is silent.
3446        base.disconnect_addr(2);
3447        assert_eq!(
3448            connect_hits.load(Ordering::Relaxed),
3449            1,
3450            "redundant disconnect_addr must not fan out"
3451        );
3452
3453        // Re-connect fires the transition.
3454        base.connect_addr(2);
3455        assert_eq!(connect_hits.load(Ordering::Relaxed), 2);
3456    }
3457
3458    /// C parity: `autoConnectAsyn` (asynManager.c:2310-2324) fires
3459    /// `asynExceptionAutoConnect` unconditionally — even setting the
3460    /// same value as the current one. Rust mirrors that so observers
3461    /// can refresh their UI after a re-confirmation, not just an edge.
3462    #[test]
3463    fn test_set_auto_connect_fires_unconditionally() {
3464        use std::sync::atomic::{AtomicUsize, Ordering};
3465
3466        let mut base = PortDriverBase::new("ac", 1, PortFlags::default());
3467        let exc_mgr = Arc::new(crate::exception::ExceptionManager::new());
3468        base.bind_exception_sink(exc_mgr.clone());
3469        let hits = Arc::new(AtomicUsize::new(0));
3470        let hits2 = hits.clone();
3471        exc_mgr.add_callback(move |event| {
3472            if event.exception == AsynException::AutoConnect {
3473                hits2.fetch_add(1, Ordering::Relaxed);
3474            }
3475        });
3476        // base.auto_connect defaults to true — setting true again
3477        // still must fire (no state-change guard in C).
3478        base.set_auto_connect(true);
3479        base.set_auto_connect(false);
3480        base.set_auto_connect(false);
3481        assert_eq!(hits.load(Ordering::Relaxed), 3);
3482    }
3483
3484    /// asyn PR #217 (asynManager.c:2322-2324): enabling auto-connect on a
3485    /// down port/device arms the connect timer; a connected port, or a
3486    /// flip to OFF, arms nothing. Pre-fix no flip armed the timer, so a
3487    /// never-connected port enabled late made one exception-wake attempt
3488    /// and then sat silent.
3489    #[test]
3490    fn auto_connect_enable_arms_connect_timer_boundaries() {
3491        // dropped while auto-connect was OFF (the disconnect edge arms
3492        // nothing then, port.rs sync_connection_edge), enabled late →
3493        // the flip itself must arm
3494        let mut base = PortDriverBase::new("act", 1, PortFlags::default());
3495        base.set_auto_connect(false);
3496        base.set_connected(false);
3497        assert!(base.connect_retry_at.is_none());
3498        base.set_auto_connect(true);
3499        assert!(base.connect_retry_at.is_some());
3500
3501        // down + OFF → untouched (C's timer callback no-ops on
3502        // !autoConnect; the flip itself must not arm)
3503        let mut base = PortDriverBase::new("act2", 1, PortFlags::default());
3504        base.set_auto_connect(false);
3505        base.set_connected(false);
3506        base.set_auto_connect(false);
3507        assert!(base.connect_retry_at.is_none());
3508
3509        // connected + ON → nothing to retry (a fresh base is born
3510        // `Connection::Own(true)`)
3511        let mut base = PortDriverBase::new("act3", 1, PortFlags::default());
3512        base.set_auto_connect(true);
3513        assert!(base.connect_retry_at.is_none());
3514
3515        // device down + ON via the addr variant → the PORT timer arms,
3516        // keyed on the device's dpCommon exactly as C's findDpCommon
3517        // resolution is; the port itself stays connected
3518        let mut base = PortDriverBase::new("act4", 2, PortFlags::default());
3519        base.device_state(1).connected = false;
3520        assert!(base.connect_retry_at.is_none());
3521        base.set_auto_connect_addr(1, true);
3522        assert!(base.connect_retry_at.is_some());
3523    }
3524
3525    #[test]
3526    fn auto_connect_throttle_gate_boundaries() {
3527        // C autoConnectDevice 2.0s gate (asynManager.c:712-713). Boundary
3528        // cases, not narrative: never-stamped, exactly-2s, just-under-2s.
3529        let mut base = PortDriverBase::new("thr", 1, PortFlags::default());
3530
3531        // No transition recorded yet => always permitted (C's
3532        // zero-initialised lastConnectDisconnect).
3533        let t0 = Instant::now();
3534        assert!(base.auto_connect_throttle_ok(-1, t0));
3535
3536        // Stamp at t0; only `+` arithmetic on Instant (no `- Duration`,
3537        // which panics on Windows when uptime < the span).
3538        base.last_connect_disconnect = Some(t0);
3539        // elapsed 0 < 2s => refused.
3540        assert!(!base.auto_connect_throttle_ok(-1, t0));
3541        // elapsed just under 2s => refused.
3542        assert!(!base.auto_connect_throttle_ok(-1, t0 + Duration::from_millis(1999)));
3543        // elapsed exactly 2s => permitted (>=).
3544        assert!(base.auto_connect_throttle_ok(-1, t0 + Duration::from_secs(2)));
3545        // elapsed well past => permitted.
3546        assert!(base.auto_connect_throttle_ok(-1, t0 + Duration::from_secs(5)));
3547    }
3548
3549    #[test]
3550    fn auto_connect_throttle_stamps_on_disconnect_not_connect() {
3551        // C exceptionDisconnect stamps lastConnectDisconnect (asynManager.c
3552        // :2184); exceptionConnect does not (:2157-2159). Mirror both edges.
3553        let mut base = PortDriverBase::new("thr", 1, PortFlags::default());
3554        // Starts connected, no stamp.
3555        assert!(base.last_connect_disconnect.is_none());
3556
3557        // Disconnect edge stamps.
3558        assert!(base.set_connected(false));
3559        assert!(base.last_connect_disconnect.is_some());
3560
3561        // Clear, then connect edge must NOT re-stamp.
3562        base.last_connect_disconnect = None;
3563        assert!(base.set_connected(true));
3564        assert!(base.last_connect_disconnect.is_none());
3565    }
3566
3567    #[test]
3568    fn auto_connect_throttle_per_device_anchor() {
3569        // Multi-device ports throttle per address (C dpCommon is per-device).
3570        let flags = PortFlags {
3571            multi_device: true,
3572            ..PortFlags::default()
3573        };
3574        let mut base = PortDriverBase::new("thr", 4, flags);
3575        let t0 = Instant::now();
3576
3577        // addr 1 disconnect stamps only addr 1's anchor.
3578        assert!(base.set_addr_connected(1, false));
3579        assert!(base.device_state(1).last_connect_disconnect.is_some());
3580        // addr 1 is throttled; addr 2 (never stamped) is still permitted.
3581        assert!(!base.auto_connect_throttle_ok(1, t0));
3582        assert!(base.auto_connect_throttle_ok(2, t0));
3583
3584        // Post-attempt stamp restarts addr 2's window.
3585        base.stamp_auto_connect_attempt(2, t0);
3586        assert!(!base.auto_connect_throttle_ok(2, t0));
3587        assert!(base.auto_connect_throttle_ok(2, t0 + Duration::from_secs(2)));
3588    }
3589
3590    #[test]
3591    fn set_enabled_refuses_defunct_port() {
3592        use std::sync::atomic::{AtomicUsize, Ordering};
3593        // C `enable` on a defunct port: asynDisabled, no `enabled` toggle,
3594        // no asynExceptionEnable fan-out (asynManager.c:2236-2241).
3595        let flags = PortFlags {
3596            destructible: true,
3597            ..PortFlags::default()
3598        };
3599        let mut base = PortDriverBase::new("def", 1, flags);
3600        let exc_mgr = Arc::new(crate::exception::ExceptionManager::new());
3601        base.bind_exception_sink(exc_mgr.clone());
3602        let enable_hits = Arc::new(AtomicUsize::new(0));
3603        let h = enable_hits.clone();
3604        exc_mgr.add_callback(move |event| {
3605            if event.exception == AsynException::Enable {
3606                h.fetch_add(1, Ordering::Relaxed);
3607            }
3608        });
3609
3610        // Shut the port down → defunct (shutdown sets enabled=false).
3611        base.shutdown_lifecycle().unwrap();
3612        assert!(base.is_defunct());
3613        assert!(!base.is_enabled());
3614
3615        let err = base.set_enabled(true).unwrap_err();
3616        match err {
3617            AsynError::Status { status, .. } => assert_eq!(status, AsynStatus::Disabled),
3618            other => panic!("expected Disabled, got {other:?}"),
3619        }
3620        assert!(!base.is_enabled(), "defunct port must not re-enable");
3621        assert_eq!(
3622            enable_hits.load(Ordering::Relaxed),
3623            0,
3624            "no Enable exception may fire on a defunct port"
3625        );
3626    }
3627
3628    #[test]
3629    fn set_addr_enabled_refuses_defunct_port() {
3630        use std::sync::atomic::{AtomicUsize, Ordering};
3631        let flags = PortFlags {
3632            multi_device: true,
3633            destructible: true,
3634            ..PortFlags::default()
3635        };
3636        let mut base = PortDriverBase::new("def", 4, flags);
3637        let exc_mgr = Arc::new(crate::exception::ExceptionManager::new());
3638        base.bind_exception_sink(exc_mgr.clone());
3639        let enable_hits = Arc::new(AtomicUsize::new(0));
3640        let h = enable_hits.clone();
3641        exc_mgr.add_callback(move |event| {
3642            if event.exception == AsynException::Enable {
3643                h.fetch_add(1, Ordering::Relaxed);
3644            }
3645        });
3646
3647        base.shutdown_lifecycle().unwrap();
3648
3649        let err = base.set_addr_enabled(1, false).unwrap_err();
3650        match err {
3651            AsynError::Status { status, .. } => assert_eq!(status, AsynStatus::Disabled),
3652            other => panic!("expected Disabled, got {other:?}"),
3653        }
3654        // The guard returns before `device_state(addr)` would insert an
3655        // entry, so the refused call mutates no per-device state.
3656        assert!(
3657            !base.device_states.contains_key(&1),
3658            "refused per-device enable must not create device state"
3659        );
3660        // The `()` convenience facade also no-ops on a defunct port.
3661        base.disable_addr(1);
3662        assert!(!base.device_states.contains_key(&1));
3663        assert_eq!(
3664            enable_hits.load(Ordering::Relaxed),
3665            0,
3666            "no Enable exception may fire on a defunct port"
3667        );
3668    }
3669
3670    /// C parity: `asynPortDriver::setInterruptUInt32Digital` /
3671    /// `clearInterruptUInt32Digital` / `getInterruptUInt32Digital`
3672    /// (`asynPortDriver.cpp:2346-2461`) route through paramList. The
3673    /// PortDriver trait default delegates to the param store; we
3674    /// verify the round-trip end-to-end through the trait surface.
3675    #[test]
3676    fn test_port_driver_uint32_interrupt_round_trip() {
3677        struct UInt32Drv {
3678            base: PortDriverBase,
3679        }
3680        impl PortDriver for UInt32Drv {
3681            fn base(&self) -> &PortDriverBase {
3682                &self.base
3683            }
3684            fn base_mut(&mut self) -> &mut PortDriverBase {
3685                &mut self.base
3686            }
3687        }
3688
3689        let mut base = PortDriverBase::new("uint32_int", 1, PortFlags::default());
3690        let idx = base
3691            .params
3692            .create_param("BITS", ParamType::UInt32Digital)
3693            .unwrap();
3694        let mut drv = UInt32Drv { base };
3695        let user = AsynUser::new(idx).with_addr(0);
3696
3697        drv.set_interrupt_uint32_digital(&user, 0xF0, InterruptReason::ZeroToOne)
3698            .unwrap();
3699        drv.set_interrupt_uint32_digital(&user, 0x0F, InterruptReason::OneToZero)
3700            .unwrap();
3701        assert_eq!(
3702            drv.get_interrupt_uint32_digital(&user, InterruptReason::Both)
3703                .unwrap(),
3704            0xFF
3705        );
3706        drv.clear_interrupt_uint32_digital(&user, 0x11).unwrap();
3707        assert_eq!(
3708            drv.get_interrupt_uint32_digital(&user, InterruptReason::ZeroToOne)
3709                .unwrap(),
3710            0xE0
3711        );
3712        assert_eq!(
3713            drv.get_interrupt_uint32_digital(&user, InterruptReason::OneToZero)
3714                .unwrap(),
3715            0x0E
3716        );
3717    }
3718
3719    /// C parity: the default `read_int32` / `read_int64` / `read_float64` /
3720    /// `read_octet` / `read_uint32_digital` must surface an *unset*
3721    /// parameter as `ParamUndefined`, not success/0. The default
3722    /// `asynPortDriver::read{Int32,Int64,Float64,Octet,UInt32Digital}` calls
3723    /// `get{Integer,Integer64,Double,String,UIntDigital}Param`, every
3724    /// `paramVal` getter throws `ParamValNotDefined` → `asynParamUndefined`
3725    /// for an unset value (paramVal.cpp:152,181,235,264,292), and the
3726    /// `devAsyn*` device support routes that status through
3727    /// `asynStatusToEpicsAlarm(READ_ALARM, INVALID_ALARM)`. After a write
3728    /// the same reads succeed with the stored value.
3729    #[test]
3730    fn default_scalar_reads_report_undefined_until_set() {
3731        struct AllTypesDrv {
3732            base: PortDriverBase,
3733        }
3734        impl PortDriver for AllTypesDrv {
3735            fn base(&self) -> &PortDriverBase {
3736                &self.base
3737            }
3738            fn base_mut(&mut self) -> &mut PortDriverBase {
3739                &mut self.base
3740            }
3741        }
3742
3743        let mut base = PortDriverBase::new("undef_read", 1, PortFlags::default());
3744        let i32_idx = base.params.create_param("I32", ParamType::Int32).unwrap();
3745        let i64_idx = base.params.create_param("I64", ParamType::Int64).unwrap();
3746        let f64_idx = base.params.create_param("F64", ParamType::Float64).unwrap();
3747        let oct_idx = base.params.create_param("OCT", ParamType::Octet).unwrap();
3748        let u32_idx = base
3749            .params
3750            .create_param("BITS", ParamType::UInt32Digital)
3751            .unwrap();
3752        let mut drv = AllTypesDrv { base };
3753
3754        // Unset → every default scalar read is ParamUndefined, NOT Ok(0).
3755        assert!(matches!(
3756            drv.read_int32(&AsynUser::new(i32_idx).with_addr(0)),
3757            Err(AsynError::ParamUndefined(_))
3758        ));
3759        assert!(matches!(
3760            drv.read_int64(&AsynUser::new(i64_idx).with_addr(0)),
3761            Err(AsynError::ParamUndefined(_))
3762        ));
3763        assert!(matches!(
3764            drv.read_float64(&AsynUser::new(f64_idx).with_addr(0)),
3765            Err(AsynError::ParamUndefined(_))
3766        ));
3767        let mut buf = [0u8; 16];
3768        assert!(matches!(
3769            drv.read_octet(&AsynUser::new(oct_idx).with_addr(0), &mut buf),
3770            Err(AsynError::ParamUndefined(_))
3771        ));
3772        assert!(matches!(
3773            drv.read_uint32_digital(&AsynUser::new(u32_idx).with_addr(0), 0xFFFF_FFFF),
3774            Err(AsynError::ParamUndefined(_))
3775        ));
3776
3777        // After a write the same reads succeed with the stored value.
3778        drv.base_mut().params.set_int32(i32_idx, 0, 7).unwrap();
3779        drv.base_mut().params.set_int64(i64_idx, 0, 9).unwrap();
3780        drv.base_mut().params.set_float64(f64_idx, 0, 1.5).unwrap();
3781        drv.base_mut()
3782            .params
3783            .set_string(oct_idx, 0, "hi".to_string())
3784            .unwrap();
3785        drv.base_mut()
3786            .params
3787            .set_uint32(u32_idx, 0, 0x05, 0xFFFF_FFFF, 0)
3788            .unwrap();
3789
3790        assert_eq!(
3791            drv.read_int32(&AsynUser::new(i32_idx).with_addr(0))
3792                .unwrap(),
3793            7
3794        );
3795        assert_eq!(
3796            drv.read_int64(&AsynUser::new(i64_idx).with_addr(0))
3797                .unwrap(),
3798            9
3799        );
3800        assert_eq!(
3801            drv.read_float64(&AsynUser::new(f64_idx).with_addr(0))
3802                .unwrap(),
3803            1.5
3804        );
3805        let n = drv
3806            .read_octet(&AsynUser::new(oct_idx).with_addr(0), &mut buf)
3807            .unwrap();
3808        assert_eq!(&buf[..n], b"hi");
3809        assert_eq!(
3810            drv.read_uint32_digital(&AsynUser::new(u32_idx).with_addr(0), 0xFFFF_FFFF)
3811                .unwrap(),
3812            0x05
3813        );
3814    }
3815
3816    /// R16-47: the parameter block is one level *late* no longer.
3817    ///
3818    /// C `asynPortDriver::report` hands `reportParams` the level it was given,
3819    /// unchanged (asynPortDriver.cpp:3692); `reportParams` prints list 0 at any
3820    /// level and all `maxAddr` lists at `details >= 2` (:1804); and
3821    /// `paramVal::report` prints name, type, value and status for every parameter
3822    /// at every level (paramVal.cpp:296-330). Passing `level - 1` made
3823    /// `asynReport 1` print a bare count, and values appear only at 3.
3824    ///
3825    /// One case per threshold boundary: details 0 (no block), 1 (list 0, with
3826    /// values), 2 (every address list).
3827    #[test]
3828    fn report_prints_the_parameter_block_at_the_c_detail_levels() {
3829        struct Drv {
3830            base: PortDriverBase,
3831        }
3832        impl PortDriver for Drv {
3833            fn base(&self) -> &PortDriverBase {
3834                &self.base
3835            }
3836            fn base_mut(&mut self) -> &mut PortDriverBase {
3837                &mut self.base
3838            }
3839        }
3840
3841        let mut base = PortDriverBase::new(
3842            "rep",
3843            2,
3844            PortFlags {
3845                multi_device: true,
3846                ..PortFlags::default()
3847            },
3848        );
3849        let n = base.params.create_param("N", ParamType::Int32).unwrap();
3850        let x = base.params.create_param("X", ParamType::Float64).unwrap();
3851        let s_idx = base.params.create_param("S", ParamType::Octet).unwrap();
3852        let bits = base
3853            .params
3854            .create_param("BITS", ParamType::UInt32Digital)
3855            .unwrap();
3856        base.params.set_int32(n, 0, 7).unwrap();
3857        base.params.set_float64(x, 0, 0.1 + 0.2).unwrap();
3858        base.params
3859            .set_string(s_idx, 0, "hello".to_string())
3860            .unwrap();
3861        base.params.set_uint32(bits, 0, 0xa5, 0xff, 0).unwrap();
3862        base.params
3863            .set_uint32_interrupt(bits, 0, 0x0f, InterruptReason::ZeroToOne)
3864            .unwrap();
3865        // Addr 1 is left untouched: its parameters must report as undefined.
3866        let drv = Drv { base };
3867
3868        // details 0 — C prints the port line and stops (:3678-3680).
3869        let mut out = String::new();
3870        drv.report(&mut out, 0);
3871        assert!(
3872            !out.contains("Parameter"),
3873            "details 0 has no parameter block: {out}"
3874        );
3875
3876        // details 1 — list 0, the count, and every parameter WITH its value.
3877        let mut out = String::new();
3878        drv.report(&mut out, 1);
3879        assert!(
3880            out.contains("Parameter list 0\nNumber of parameters is: 4\n"),
3881            "C's paramList::report header (asynPortDriver.cpp:887): {out}"
3882        );
3883        assert!(
3884            out.contains("Parameter 0 type=asynInt32, name=N, value=7, status=0\n"),
3885            "{out}"
3886        );
3887        // C's `%g`: six significant digits, so 0.1+0.2 is `0.3`, not
3888        // `0.30000000000000004`.
3889        assert!(
3890            out.contains("Parameter 1 type=asynFloat64, name=X, value=0.3, status=0\n"),
3891            "{out}"
3892        );
3893        assert!(
3894            out.contains("Parameter 2 type=string, name=S, value=hello, status=0\n"),
3895            "C calls an octet parameter `string` (paramVal.cpp:328): {out}"
3896        );
3897        assert!(
3898            out.contains(
3899                "Parameter 3 type=asynUInt32Digital, name=BITS, value=0xa5, status=0, \
3900                 risingMask=0xf, fallingMask=0x0, callbackMask=0xa5\n"
3901            ),
3902            "C prints the three masks with the value (paramVal.cpp:314-316): {out}"
3903        );
3904        assert!(
3905            !out.contains("Parameter list 1"),
3906            "below details 2 C reports one address list (asynPortDriver.cpp:1804): {out}"
3907        );
3908
3909        // details 2 — every address list, and addr 1 is undefined.
3910        let mut out = String::new();
3911        drv.report(&mut out, 2);
3912        assert!(out.contains("Parameter list 1\n"), "{out}");
3913        assert!(
3914            out.contains("Parameter 0 type=asynInt32, name=N, value is undefined\n"),
3915            "an unset parameter prints C's undefined line (paramVal.cpp:304): {out}"
3916        );
3917    }
3918
3919    /// R16-48: the report escapes a terminator the way C does — the whole libCom
3920    /// table, not just CR and LF.
3921    ///
3922    /// C prints the EOS pair with `epicsStrPrintEscaped` (asynPortDriver.cpp:3687,
3923    /// 3690), whose table is `\a \b \f \n \r \t \v \\ \' \"`, the byte itself
3924    /// when `isprint`, and `\xNN` otherwise (epicsString.c:230-262). The report's
3925    /// own two-case table wrote a binary terminator raw into stdout.
3926    #[test]
3927    fn report_escapes_the_eos_with_the_c_table() {
3928        struct Drv {
3929            base: PortDriverBase,
3930        }
3931        impl PortDriver for Drv {
3932            fn base(&self) -> &PortDriverBase {
3933                &self.base
3934            }
3935            fn base_mut(&mut self) -> &mut PortDriverBase {
3936                &mut self.base
3937            }
3938            fn capabilities(&self) -> Vec<crate::interfaces::Capability> {
3939                crate::interfaces::octet_transport_capabilities()
3940            }
3941        }
3942
3943        let mut drv = Drv {
3944            base: PortDriverBase::new("eos_rep", 1, PortFlags::default()),
3945        };
3946        // A real binary terminator (C caps an EOS at two bytes): ESC, then NUL —
3947        // neither of which the old two-case table escaped.
3948        drv.set_input_eos(&AsynUser::default(), b"\x1b\0").unwrap();
3949        // …and the named escapes beyond CR/LF: TAB and BEL.
3950        drv.set_output_eos(&AsynUser::default(), b"\t\x07").unwrap();
3951
3952        let mut out = String::new();
3953        drv.report(&mut out, 1);
3954        assert!(
3955            out.contains("  Input EOS[2]: \\x1b\\0\n"),
3956            "CBUG-D4 refused: C's epicsStrPrintEscaped lacked a `case 0` (epicsString.c:255-260) and printed \\x00; the port supplies it so NUL renders \\0, matching epicsStrnEscapedFromRaw: {out}"
3957        );
3958        assert!(
3959            out.contains("  Output EOS[2]: \\t\\a\n"),
3960            "BEL is `\\a` in C's table, not `\\x07` (epicsString.c:245): {out}"
3961        );
3962    }
3963
3964    /// R16-49: the report has no options block, at any level.
3965    ///
3966    /// C `asynPortDriver::report` (asynPortDriver.cpp:3677-3710) prints the port
3967    /// name, the timestamp, the EOS pair, the parameter lists and — at
3968    /// `details >= 3` — the interrupt clients. It never prints options, and it
3969    /// could not: `asynOption` is a `getOption`/`setOption` pair keyed by a
3970    /// driver-defined string, with no enumeration to walk.
3971    #[test]
3972    fn report_prints_no_options_block() {
3973        struct Drv {
3974            base: PortDriverBase,
3975        }
3976        impl PortDriver for Drv {
3977            fn base(&self) -> &PortDriverBase {
3978                &self.base
3979            }
3980            fn base_mut(&mut self) -> &mut PortDriverBase {
3981                &mut self.base
3982            }
3983        }
3984
3985        let mut drv = Drv {
3986            base: PortDriverBase::new("opt_rep", 1, PortFlags::default()),
3987        };
3988        drv.set_option(&mut AsynUser::default(), "baud", "9600")
3989            .unwrap();
3990
3991        for level in 0..=4 {
3992            let mut out = String::new();
3993            drv.report(&mut out, level);
3994            assert!(
3995                !out.contains("option") && !out.contains("baud"),
3996                "asynReport {level} must print no options block: {out}"
3997            );
3998        }
3999    }
4000}