Skip to main content

pnio/cm/
mod.rs

1//! PROFINET Context Manager (CM): PNIO status, PNIO block header, and the
2//! Connect-request block parsers (ARBlockReq, IOCRBlockReq, ExpectedSubmoduleBlockReq,
3//! AlarmCRBlockReq) needed to establish an Application Relationship (AR).
4
5pub mod ar;
6pub mod block;
7pub mod connect;
8pub mod control;
9pub mod model;
10pub mod records;
11pub mod status;
12pub mod write;
13
14pub use ar::{
15    AbortReason, Action, Ar, ArContext, ArState, Event, ACTIVITY_TIMEOUT_UNIT,
16    APP_READY_MAX_ATTEMPTS, APP_READY_TIMEOUT,
17};
18pub use block::{
19    ty, AlarmCrBlockReq, ArBlockReq, BlockHeader, Cursor, DataDescription, ExpectedApi,
20    ExpectedSubmodule, ExpectedSubmoduleBlockReq, IocrApi, IocrBlockReq, IocrObject,
21};
22pub use connect::{build_connect_res, validate, ArParams, ConnectReq, IocrParams};
23pub use control::{app_ready_req, cmd, prm_end_done, release_done, ControlBlock};
24pub use model::{DeviceModel, SlotModel, SubmoduleModel};
25pub use records::{build_read_res, read_record, write_im_record, ReadReq, RecordCtx};
26pub use status::{ConnectBlock, PnioStatus};
27pub use write::{build_write_res, Record, WriteReq, INDEX_MULTIPLE_WRITE};
28
29use crate::im::{Im0, ImStore, INDEX_IM1, INDEX_IM3};
30use crate::rpc::{
31    Drep, NdrRequest, NdrResponse, Opnum, PacketType, RpcError, RpcHeader, Uuid, FLAG1_IDEMPOTENT,
32    FLAG1_NO_FACK, PNIO_CONTROLLER_INTERFACE, PNIO_DEVICE_INTERFACE, PNIO_UDP_PORT,
33};
34use std::collections::VecDeque;
35use std::net::SocketAddr;
36use std::time::Instant;
37use thiserror::Error;
38
39/// Errors from parsing/serializing PNIO blocks (the 6-byte header and the per-type bodies).
40#[derive(Debug, Error, PartialEq, Eq)]
41pub enum BlockError {
42    /// Fewer bytes available than the block shape being parsed needs.
43    #[error("block buffer too short: need {need}, have {have}")]
44    TooShort {
45        /// Bytes the parse needs.
46        need: usize,
47        /// Bytes actually available.
48        have: usize,
49    },
50    /// The block header's `BlockType` is not the one the caller asked to parse.
51    #[error("unexpected block type: expected {expected:#06x}, got {got:#06x}")]
52    UnexpectedType {
53        /// `BlockType` the caller expected.
54        expected: u16,
55        /// `BlockType` actually found.
56        got: u16,
57    },
58    /// The block header's version is not `1.0`.
59    #[error("bad block version {0}.{1} (expected 1.0)")]
60    BadVersion(u8, u8),
61    /// The block header's `BlockLength` declares more bytes than are available.
62    #[error("bad block length: declared {declared}, available {available}")]
63    BadLength {
64        /// `BlockLength` as declared in the block header.
65        declared: u16,
66        /// Bytes actually available after the header.
67        available: usize,
68    },
69    /// The block parsed to the right length and type but its content violates a
70    /// structural rule (e.g. a count field disagreeing with the actual data).
71    #[error("malformed block: {0}")]
72    Malformed(&'static str),
73}
74
75/// Errors from the Context Manager's AR establishment / lifecycle handling.
76#[derive(Debug, Error, PartialEq, Eq)]
77pub enum CmError {
78    /// A PNIO block failed to parse; see [`BlockError`].
79    #[error("block error: {0}")]
80    Block(#[from] BlockError),
81    /// The request is well-formed but the AR/CM logic rejects it with this status.
82    #[error("connect rejected: {0:?}")]
83    Reject(PnioStatus),
84}
85
86// ---------------------------------------------------------------------------------
87// Cm: RPC datagram <-> AR glue.
88// ---------------------------------------------------------------------------------
89
90/// Our advertised max NDR args length (`args_max`/`max_count`) for outgoing calls —
91/// p-net's value, required byte-for-byte by the golden ApplicationReady request.
92pub const RPC_ARGS_MAX: u32 = 1340;
93
94/// Maximum number of cached full response PDUs kept for retransmission, FIFO eviction.
95const CACHE_CAPACITY: usize = 4;
96
97/// One outgoing RPC datagram: the raw bytes to send and the address to send them to.
98#[derive(Debug, Clone, PartialEq)]
99pub struct Outgoing {
100    /// The complete UDP payload (RPC header + NDR body) to send.
101    pub bytes: Vec<u8>,
102    /// Destination address.
103    pub to: SocketAddr,
104}
105
106/// The effects of one `Cm::handle_datagram`/`Cm::tick` call: datagrams to send, and AR
107/// state-change notifications to report (state plus the abort reason, when any).
108#[derive(Debug, Default, PartialEq)]
109pub struct CmOutput {
110    /// Datagrams the caller must send on the wire, in order.
111    pub send: Vec<Outgoing>,
112    /// AR state transitions to report to the application, each with the reason when
113    /// the transition is a fall back to [`ArState::Idle`] due to an abort.
114    pub notify: Vec<(ArState, Option<AbortReason>)>,
115}
116
117/// The request-specific fields needed to build and cache a Response PDU, gathered
118/// from the incoming request's RPC header plus its NDR `args_max`.
119struct RespondCtx {
120    object: Uuid,
121    interface: Uuid,
122    activity: Uuid,
123    seq_num: u32,
124    opnum: u16,
125    args_max: u32,
126    from: SocketAddr,
127}
128
129/// Maps a block-parsing failure to the PNIO status to answer with: `Reject(status)`
130/// carries its own status through unchanged; a `Block` error logs and falls back to
131/// `default` (the caller's per-opnum convention).
132fn error_status(e: &CmError, default: PnioStatus) -> PnioStatus {
133    match e {
134        CmError::Reject(status) => *status,
135        CmError::Block(be) => {
136            log::warn!("PNIO block parse error: {be}");
137            default
138        }
139    }
140}
141
142fn respond(status: PnioStatus) -> Vec<Action> {
143    vec![Action::Respond {
144        status,
145        blocks: Vec::new(),
146    }]
147}
148
149fn respond_ok(blocks: Vec<u8>) -> Vec<Action> {
150    vec![Action::Respond {
151        status: PnioStatus::OK,
152        blocks,
153    }]
154}
155
156/// RPC datagram <-> AR glue: parses the DCE-RPC/NDR/PNIO envelope of an incoming UDP
157/// datagram, feeds the pure [`Ar`] state machine the resulting [`Event`], and wraps its
158/// [`Action`]s back into complete RPC PDUs — byte-exact against the golden p-net <-> CPU
159/// capture. Also owns the small response cache (a retransmitted request is answered with
160/// the identical cached bytes instead of re-running the AR machine) and the call sequence
161/// number / controller address used for our outgoing ApplicationReady calls.
162pub struct Cm {
163    ar: Ar,
164    /// Own copy of the device model (`Ar` holds one too, for Connect validation):
165    /// needed here to serve I&M reads/writes without a model accessor on `Ar`.
166    model: DeviceModel,
167    /// Device identity answered by I&M0 reads; `Im0::default()` until [`Cm::set_im`]
168    /// is called.
169    im0: Im0,
170    /// The writable I&M1-3 records; blank, unpersisted, until [`Cm::set_im`] is
171    /// called.
172    im: ImStore,
173    /// Activity UUID used for calls we place to the controller (ApplicationReady).
174    activity_seed: Uuid,
175    /// Seq num to assign to the *next new* outgoing call to the controller.
176    call_seq: u32,
177    /// Seq num of the current outstanding call, reused unchanged by `tick`'s retries.
178    current_call_seq: Option<u32>,
179    /// Controller address for outgoing calls, learned from the Connect request's
180    /// source address (its IP, PNIO's well-known UDP port).
181    controller_addr: Option<SocketAddr>,
182    /// Cached full response PDUs, keyed by `(activity, seq_num)`, FIFO eviction at
183    /// [`CACHE_CAPACITY`] entries.
184    cache: VecDeque<((Uuid, u32), Vec<u8>)>,
185}
186
187impl Cm {
188    /// A fresh Context Manager for `model`, idle (no AR). `activity_seed` is the
189    /// activity UUID used for calls this device places to the controller
190    /// (ApplicationReady); it should be stable across the device's lifetime.
191    pub fn new(model: DeviceModel, activity_seed: Uuid) -> Cm {
192        Cm {
193            model: model.clone(),
194            ar: Ar::new(model),
195            im0: Im0::default(),
196            im: ImStore::new(),
197            activity_seed,
198            call_seq: 0,
199            current_call_seq: None,
200            controller_addr: None,
201            cache: VecDeque::new(),
202        }
203    }
204
205    /// The AR's current lifecycle state.
206    pub fn state(&self) -> ArState {
207        self.ar.state()
208    }
209
210    /// The current AR's negotiated context (parameters, layout, ...), if one is established.
211    pub fn context(&self) -> Option<&ArContext> {
212        self.ar.context()
213    }
214
215    /// The next time the caller must call [`Cm::tick`] (a pending timeout), or `None`
216    /// if nothing is outstanding.
217    pub fn next_deadline(&self) -> Option<Instant> {
218        self.ar.next_deadline()
219    }
220
221    /// Replace the device identity answered by I&M0 reads and the writable I&M1-3
222    /// store (e.g. once loaded from config/a backing file at startup). Until called,
223    /// `Cm` answers with `Im0::default()` and blank I&M1-3 records.
224    pub fn set_im(&mut self, im0: Im0, store: ImStore) {
225        self.im0 = im0;
226        self.im = store;
227    }
228
229    /// The current I&M1-3 store (e.g. so the application can read back tag
230    /// function/location after a controller Write).
231    pub fn im_store(&self) -> &ImStore {
232        &self.im
233    }
234
235    /// Parse one incoming UDP datagram and drive the AR machine, returning the PDUs to
236    /// send and the AR notifications to report. RPC envelope errors ([`RpcHeader::parse`],
237    /// [`NdrRequest::parse`]/[`NdrResponse::parse`], a foreign interface UUID) propagate to
238    /// the caller to log and drop; PNIO-block-level parse failures are answered in-band
239    /// with the appropriate PNIO status instead.
240    pub fn handle_datagram(
241        &mut self,
242        buf: &[u8],
243        from: SocketAddr,
244        now: Instant,
245    ) -> Result<CmOutput, RpcError> {
246        let h = RpcHeader::parse(buf)?;
247        let body = &buf[RpcHeader::LEN..];
248        let mut output = CmOutput::default();
249
250        if h.ptype == PacketType::Response {
251            // The controller answering our ApplicationReady call.
252            let (n, _blocks) = NdrResponse::parse(body, h.drep)?;
253            let actions = self.ar.on(
254                Event::AppReadyRsp {
255                    status: PnioStatus(n.status),
256                },
257                now,
258            );
259            self.apply(actions, None, false, &mut output);
260            return Ok(output);
261        }
262        if h.ptype != PacketType::Request {
263            log::warn!(
264                "dropping DCE-RPC packet type {:?}, only Request/Response handled",
265                h.ptype
266            );
267            return Ok(output);
268        }
269
270        if h.interface != PNIO_DEVICE_INTERFACE {
271            return Err(RpcError::BadInterface(h.interface));
272        }
273
274        let cache_key = (h.activity, h.seq_num);
275        if let Some(bytes) = self.cache_lookup(&cache_key) {
276            log::debug!(
277                "cache hit for activity {} seq_num {}: resending cached response",
278                h.activity,
279                h.seq_num
280            );
281            output.send.push(Outgoing { bytes, to: from });
282            return Ok(output);
283        }
284
285        let (req, blocks) = NdrRequest::parse(body, h.drep)?;
286
287        let actions = match Opnum::from_u16(h.opnum) {
288            Some(Opnum::Connect) => {
289                let actions = match ConnectReq::parse(blocks) {
290                    Ok(req) => self.ar.on(Event::ConnectReq(req), now),
291                    Err(e) => respond(error_status(
292                        &e,
293                        PnioStatus::connect_reject(ConnectBlock::ArBlock, 0xfe),
294                    )),
295                };
296                // Remember the controller's address (PrmEnd, later on the same AR, needs
297                // it to place the ApplicationReady call, and `tick`'s retries need it with
298                // no request in hand at all), and drop any stale outstanding-call seq_num,
299                // whenever this Connect actually (re-)established the AR: the nominal
300                // Idle -> Connected path, or a controller-reconnect takeover (`Ar` aborts
301                // the stale AR and re-establishes a new one within the same `on()` call, so
302                // both `Notify`s appear in `actions`). A stray Connect from another host
303                // while an AR already exists (refused with `connect_ar_already_exists`) or
304                // a rejected/malformed one produces no `Notify { state: Connected, .. }`
305                // and must not redirect the real AR's outgoing calls.
306                if actions.iter().any(|a| {
307                    matches!(
308                        a,
309                        Action::Notify {
310                            state: ArState::Connected,
311                            ..
312                        }
313                    )
314                }) {
315                    self.controller_addr = Some(SocketAddr::new(from.ip(), PNIO_UDP_PORT));
316                    // `call_seq` is NOT reset here: it's scoped to `activity_seed`, which
317                    // is fixed for the device's lifetime, not per-AR. DCE-RPC CL duplicate
318                    // detection is per `(activity, seq_num)` with a monotonically
319                    // increasing seq_num — the same rule our own response cache relies on
320                    // — so restarting it at 0 on a reconnect would re-send `(activity_seed,
321                    // 0)`, a pair the controller already completed for the previous AR, and
322                    // risk being discarded as a duplicate.
323                    self.current_call_seq = None;
324                }
325                actions
326            }
327            Some(Opnum::Write) => match WriteReq::parse(blocks) {
328                Ok(req) => {
329                    // Kept so the I&M1-3 records can be inspected below: `Event::
330                    // WriteReq` consumes `req`, but only the AR machine gets to
331                    // decide whether the Write is accepted at all (foreign ar_uuid,
332                    // wrong state, record cap).
333                    let records = req.records.clone();
334                    let actions = self.ar.on(Event::WriteReq(req), now);
335                    let accepted = actions
336                        .iter()
337                        .any(|a| matches!(a, Action::Respond { status, .. } if status.is_ok()));
338                    if accepted {
339                        for r in &records {
340                            if (INDEX_IM1..=INDEX_IM3).contains(&r.index) {
341                                let status = write_im_record(r, &self.model, &mut self.im);
342                                if !status.is_ok() {
343                                    log::warn!(
344                                        "I&M write rejected: index {:#06x} slot {} \
345                                         subslot {} status {:#010x}",
346                                        r.index,
347                                        r.slot,
348                                        r.subslot,
349                                        status.to_u32()
350                                    );
351                                }
352                            }
353                        }
354                    }
355                    actions
356                }
357                Err(e) => respond(error_status(&e, PnioStatus::write_index_unsupported())),
358            },
359            Some(Opnum::Control) => match ControlBlock::parse(blocks) {
360                Ok(cb) if cb.command == cmd::PRM_END => self.ar.on(Event::PrmEndReq(cb), now),
361                Ok(_) => respond(PnioStatus::control_wrong_state()),
362                Err(e) => respond(error_status(&e, PnioStatus::control_wrong_state())),
363            },
364            Some(Opnum::Release) => match ControlBlock::parse(blocks) {
365                Ok(cb) => self.ar.on(Event::ReleaseReq(cb), now),
366                Err(e) => respond(error_status(&e, PnioStatus::control_wrong_state())),
367            },
368            // `Read` requires the request's `ar_uuid` to match the established AR
369            // (or an established AR to exist at all); `ReadImplicit` skips that
370            // check — it's meant to be usable before/without an AR (e.g. the CPU's
371            // periodic I&M probes). Both fall back to the PNIORW "invalid index"
372            // status for an index/(slot, subslot) we don't serve, rather than
373            // `service_unsupported`, so an unrecognized probe decodes sensibly in a
374            // trace instead of looking like a Connect-block error.
375            Some(Opnum::Read) => match ReadReq::parse(blocks) {
376                Ok(req) => self.handle_read(&req, true),
377                Err(e) => respond(error_status(&e, PnioStatus::read_index_unsupported())),
378            },
379            Some(Opnum::ReadImplicit) => match ReadReq::parse(blocks) {
380                Ok(req) => self.handle_read(&req, false),
381                Err(e) => respond(error_status(&e, PnioStatus::read_index_unsupported())),
382            },
383            None => respond(PnioStatus::service_unsupported()),
384        };
385
386        let ctx = RespondCtx {
387            object: h.object,
388            interface: h.interface,
389            activity: h.activity,
390            seq_num: h.seq_num,
391            opnum: h.opnum,
392            args_max: req.args_max,
393            from,
394        };
395        self.apply(actions, Some((ctx, cache_key)), true, &mut output);
396        Ok(output)
397    }
398
399    /// Drive the AR machine's timers (activity / ApplicationReady retry) and wrap any
400    /// resulting retry call or notification.
401    pub fn tick(&mut self, now: Instant) -> CmOutput {
402        let mut output = CmOutput::default();
403        let actions = self.ar.on(Event::Tick, now);
404        self.apply(actions, None, false, &mut output);
405        output
406    }
407
408    /// Force the AR to `Idle` for an externally-observed `reason` (e.g. the RT
409    /// runner's consumer watchdog expiring), mapping the resulting actions exactly
410    /// like [`Cm::tick`] does. A no-op (no `Notify`) if the AR is already `Idle`.
411    pub fn abort(&mut self, reason: AbortReason, now: Instant) -> CmOutput {
412        let mut output = CmOutput::default();
413        let actions = self.ar.on(Event::Abort(reason), now);
414        self.apply(actions, None, false, &mut output);
415        output
416    }
417
418    /// Turn a batch of `Ar` actions into `CmOutput`: build+cache a Response PDU for each
419    /// `Respond`, build a Request PDU to the controller for each `CallController`
420    /// (`call_is_new` selects a fresh call sequence number vs. reusing the outstanding
421    /// one, for `tick`'s retries), and forward `Notify`s unchanged.
422    fn apply(
423        &mut self,
424        actions: Vec<Action>,
425        resp_ctx: Option<(RespondCtx, (Uuid, u32))>,
426        call_is_new: bool,
427        output: &mut CmOutput,
428    ) {
429        for action in actions {
430            match action {
431                Action::Respond { status, blocks } => match &resp_ctx {
432                    Some((ctx, cache_key)) => {
433                        let pdu = build_response_pdu(ctx, status, blocks);
434                        self.cache_insert(*cache_key, pdu.clone());
435                        output.send.push(Outgoing {
436                            bytes: pdu,
437                            to: ctx.from,
438                        });
439                    }
440                    None => {
441                        log::warn!("Respond action with no request context; dropping");
442                    }
443                },
444                Action::CallController { blocks } => {
445                    let to = self
446                        .controller_addr
447                        .expect("controller_addr set at Connect before any AR can reach PrmEnd");
448                    let pdu = self.build_call_pdu(blocks, call_is_new);
449                    output.send.push(Outgoing { bytes: pdu, to });
450                }
451                Action::Notify { state, reason } => output.notify.push((state, reason)),
452            }
453        }
454    }
455
456    /// Build the ApplicationReady request PDU: fresh call (`is_new`) advances
457    /// `call_seq` and remembers it as the outstanding call; a retry reuses it unchanged.
458    fn build_call_pdu(&mut self, blocks: Vec<u8>, is_new: bool) -> Vec<u8> {
459        let object = self
460            .ar
461            .context()
462            .expect("ctx present for CallController action")
463            .params
464            .initiator_object_uuid;
465        let seq_num = if is_new {
466            let seq_num = self.call_seq;
467            self.call_seq += 1;
468            self.current_call_seq = Some(seq_num);
469            seq_num
470        } else {
471            self.current_call_seq
472                .expect("retry call with no prior new call")
473        };
474        let header = RpcHeader {
475            ptype: PacketType::Request,
476            flags1: FLAG1_IDEMPOTENT,
477            flags2: 0,
478            drep: Drep::BIG,
479            serial_hi: 0,
480            object,
481            interface: PNIO_CONTROLLER_INTERFACE,
482            activity: self.activity_seed,
483            server_boot: 0,
484            if_version: 1,
485            seq_num,
486            opnum: Opnum::Control.to_u16(),
487            ihint: 0xffff,
488            ahint: 0xffff,
489            frag_len: (NdrRequest::LEN + blocks.len()) as u16,
490            frag_num: 0,
491            auth_proto: 0,
492            serial_lo: 0,
493        };
494        let ndr = NdrRequest::for_blocks(RPC_ARGS_MAX, blocks.len() as u32);
495        let mut pdu = Vec::with_capacity(RpcHeader::LEN + NdrRequest::LEN + blocks.len());
496        header.write(&mut pdu);
497        ndr.write(&mut pdu, Drep::BIG);
498        pdu.extend_from_slice(&blocks);
499        pdu
500    }
501
502    /// Serve a Read or ReadImplicit request: `check_ar` gates the AR-uuid check
503    /// (`Read` requires it to match the established AR; `ReadImplicit` skips it).
504    fn handle_read(&self, req: &ReadReq, check_ar: bool) -> Vec<Action> {
505        if check_ar {
506            let matches_ar = match self.ar.context() {
507                Some(ctx) => ctx.params.ar_uuid == req.ar_uuid,
508                None => false,
509            };
510            if !matches_ar {
511                return respond(PnioStatus::read_wrong_ar());
512            }
513        }
514        let ctx = RecordCtx {
515            model: &self.model,
516            im0: &self.im0,
517            im: &self.im,
518        };
519        match read_record(req, &ctx) {
520            Some(data) => respond_ok(build_read_res(req, &data)),
521            None => respond(PnioStatus::read_index_unsupported()),
522        }
523    }
524
525    fn cache_lookup(&self, key: &(Uuid, u32)) -> Option<Vec<u8>> {
526        self.cache
527            .iter()
528            .find(|(k, _)| k == key)
529            .map(|(_, pdu)| pdu.clone())
530    }
531
532    fn cache_insert(&mut self, key: (Uuid, u32), pdu: Vec<u8>) {
533        if self.cache.len() >= CACHE_CAPACITY {
534            self.cache.pop_front();
535        }
536        self.cache.push_back((key, pdu));
537    }
538}
539
540/// Build the Response PDU for a request's [`Action::Respond`]: an OK status echoes the
541/// blocks, any other status answers with no blocks (the NDR header still carries the
542/// request's `args_max` in `max_count`, matching p-net's convention).
543fn build_response_pdu(ctx: &RespondCtx, status: PnioStatus, blocks: Vec<u8>) -> Vec<u8> {
544    let out_blocks = if status.is_ok() { blocks } else { Vec::new() };
545    let header = RpcHeader {
546        ptype: PacketType::Response,
547        flags1: FLAG1_IDEMPOTENT | FLAG1_NO_FACK,
548        flags2: 0,
549        drep: Drep::BIG,
550        serial_hi: 0,
551        object: ctx.object,
552        interface: ctx.interface,
553        activity: ctx.activity,
554        server_boot: 0,
555        if_version: 1,
556        seq_num: ctx.seq_num,
557        opnum: ctx.opnum,
558        ihint: 0xffff,
559        ahint: 0xffff,
560        frag_len: (NdrResponse::LEN + out_blocks.len()) as u16,
561        frag_num: 0,
562        auth_proto: 0,
563        serial_lo: 0,
564    };
565    let ndr = if status.is_ok() {
566        NdrResponse::ok(ctx.args_max, out_blocks.len() as u32)
567    } else {
568        NdrResponse::error(status.to_u32(), ctx.args_max)
569    };
570    let mut pdu = Vec::with_capacity(RpcHeader::LEN + NdrResponse::LEN + out_blocks.len());
571    header.write(&mut pdu);
572    ndr.write(&mut pdu, Drep::BIG);
573    pdu.extend_from_slice(&out_blocks);
574    pdu
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580    use crate::cm::model::DeviceModel;
581    use crate::eth::MacAddr;
582    use crate::im::SwRevision;
583    use crate::rpc::Uuid;
584    use crate::testutil::{golden, golden_alarm, RPC_OFF};
585    use std::net::{IpAddr, Ipv4Addr, SocketAddr};
586    use std::time::Instant;
587
588    /// Replace every 16-byte window of `bytes` matching `old`'s wire encoding
589    /// (PNIO blocks are always big-endian, regardless of the surrounding RPC
590    /// `drep`) with `new`'s.
591    fn retag_ar_uuid(mut bytes: Vec<u8>, old: Uuid, new: Uuid) -> Vec<u8> {
592        let mut old_b = Vec::new();
593        old.write(&mut old_b, Drep::BIG);
594        let mut new_b = Vec::new();
595        new.write(&mut new_b, Drep::BIG);
596        let mut i = 0;
597        while i + 16 <= bytes.len() {
598            if bytes[i..i + 16] == old_b[..] {
599                bytes[i..i + 16].copy_from_slice(&new_b);
600            }
601            i += 1;
602        }
603        bytes
604    }
605
606    const MAC: MacAddr = MacAddr([0x8c, 0xf3, 0x19, 0xcd, 0x19, 0xf8]);
607    fn cpu() -> SocketAddr {
608        SocketAddr::new(IpAddr::V4(Ipv4Addr::new(172, 16, 2, 100)), 54766)
609    }
610    fn cpu_cm() -> SocketAddr {
611        SocketAddr::new(IpAddr::V4(Ipv4Addr::new(172, 16, 2, 100)), 34964)
612    }
613    fn cm() -> Cm {
614        Cm::new(
615            DeviceModel::pnet_sample(MAC),
616            Uuid::parse_str("14af198a-1234-1056-8079-8cf319cd19f8").unwrap(),
617        )
618    }
619    fn pdu(name: &str) -> Vec<u8> {
620        golden(name)[RPC_OFF..].to_vec()
621    }
622
623    #[test]
624    fn full_exchange_is_byte_exact_including_rpc_headers() {
625        let mut cm = cm();
626        let now = Instant::now();
627        let o = cm.handle_datagram(&pdu("connect_req"), cpu(), now).unwrap();
628        assert_eq!(o.send.len(), 1);
629        assert_eq!(o.send[0].bytes, pdu("connect_res"));
630        assert_eq!(o.send[0].to, cpu());
631        assert_eq!(o.notify, vec![(ArState::Connected, None)]);
632        let o = cm.handle_datagram(&pdu("write_req"), cpu(), now).unwrap();
633        assert_eq!(o.send[0].bytes, pdu("write_res"));
634        let o = cm.handle_datagram(&pdu("prmend_req"), cpu(), now).unwrap();
635        assert_eq!(o.send[0].bytes, pdu("prmend_res"));
636        assert_eq!(o.send[1].bytes, pdu("appready_req"));
637        assert_eq!(o.send[1].to, cpu_cm());
638        assert_eq!(cm.state(), ArState::AppReadySent);
639        let o = cm
640            .handle_datagram(&pdu("appready_res"), cpu_cm(), now)
641            .unwrap();
642        assert!(o.send.is_empty());
643        assert_eq!(o.notify, vec![(ArState::Data, None)]);
644        assert_eq!(cm.state(), ArState::Data);
645    }
646
647    #[test]
648    fn retransmitted_request_gets_cached_response() {
649        let mut cm = cm();
650        let now = Instant::now();
651        let first = cm.handle_datagram(&pdu("connect_req"), cpu(), now).unwrap();
652        let again = cm.handle_datagram(&pdu("connect_req"), cpu(), now).unwrap();
653        assert_eq!(again.send[0].bytes, first.send[0].bytes);
654        assert!(again.notify.is_empty());
655    }
656
657    #[test]
658    fn stray_connect_does_not_redirect_app_ready() {
659        let mut cm = cm();
660        let now = Instant::now();
661        let o = cm.handle_datagram(&pdu("connect_req"), cpu(), now).unwrap();
662        assert_eq!(o.notify, vec![(ArState::Connected, None)]);
663
664        // Same Connect request bytes, but from a different host, with a different AR
665        // UUID, initiator object UUID, and seq_num (so it's a distinct RPC call, not
666        // a cached-response retransmit, and not a same-initiator reconnect either):
667        // the AR is already `Connected`, so this must be refused without touching
668        // `controller_addr`.
669        let mut stray = pdu("connect_req");
670        stray[64] = 1; // seq_num (LE low byte): 0 -> 1
671        stray[108..124].copy_from_slice(&[0x11; 16]); // ARBlockReq.ARUUID
672        stray[132..148].copy_from_slice(&[0x22; 16]); // ARBlockReq.InitiatorObjectUUID
673        let stray_from = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(172, 16, 2, 77)), 40000);
674        let o = cm.handle_datagram(&stray, stray_from, now).unwrap();
675        let (n, _) =
676            crate::rpc::NdrResponse::parse(&o.send[0].bytes[80..], crate::rpc::Drep::BIG).unwrap();
677        assert_eq!(
678            PnioStatus(n.status),
679            PnioStatus::connect_ar_already_exists()
680        );
681        assert_eq!(cm.state(), ArState::Connected);
682
683        // The real AR's ApplicationReady call (via PrmEnd) must still go to the real
684        // controller, not to the stray host.
685        let o = cm.handle_datagram(&pdu("prmend_req"), cpu(), now).unwrap();
686        assert_eq!(o.send[1].bytes, pdu("appready_req"));
687        assert_eq!(o.send[1].to, cpu_cm());
688    }
689
690    #[test]
691    fn unsupported_opnum_gets_error_status_response() {
692        let mut cm = cm();
693        let mut read = pdu("prmend_req");
694        read[68] = 2; // opnum Read (LE low byte)
695        let o = cm.handle_datagram(&read, cpu(), Instant::now()).unwrap();
696        let h = crate::rpc::RpcHeader::parse(&o.send[0].bytes).unwrap();
697        assert_eq!(h.ptype, crate::rpc::PacketType::Response);
698        assert_eq!(h.opnum, 2);
699        let (n, blocks) =
700            crate::rpc::NdrResponse::parse(&o.send[0].bytes[80..], crate::rpc::Drep::BIG).unwrap();
701        assert_eq!(PnioStatus(n.status), PnioStatus::read_index_unsupported());
702        assert!(blocks.is_empty());
703    }
704
705    #[test]
706    fn unknown_opnum_gets_service_unsupported_status() {
707        let mut cm = cm();
708        let mut bad = pdu("prmend_req");
709        bad[68] = 99; // opnum unmapped by Opnum::from_u16 (LE low byte)
710        let o = cm.handle_datagram(&bad, cpu(), Instant::now()).unwrap();
711        let (n, blocks) =
712            crate::rpc::NdrResponse::parse(&o.send[0].bytes[80..], crate::rpc::Drep::BIG).unwrap();
713        assert_eq!(PnioStatus(n.status), PnioStatus::service_unsupported());
714        assert!(blocks.is_empty());
715    }
716
717    #[test]
718    fn rejected_connect_has_error_status_and_no_blocks() {
719        let mut cm = Cm::new(
720            {
721                let mut m = DeviceModel::pnet_sample(MAC);
722                m.slots.pop();
723                m
724            },
725            Uuid::NIL,
726        );
727        let o = cm
728            .handle_datagram(&pdu("connect_req"), cpu(), Instant::now())
729            .unwrap();
730        let (n, blocks) =
731            crate::rpc::NdrResponse::parse(&o.send[0].bytes[80..], crate::rpc::Drep::BIG).unwrap();
732        assert_eq!(
733            PnioStatus(n.status),
734            PnioStatus::connect_reject(ConnectBlock::ExpectedSubmodule, 7)
735        );
736        assert!(blocks.is_empty());
737        assert_eq!(cm.state(), ArState::Idle);
738    }
739
740    #[test]
741    fn wrong_interface_and_garbage_are_errors_not_panics() {
742        let mut cm = cm();
743        let mut bad = pdu("connect_req");
744        bad[24] = 0xff;
745        assert!(matches!(
746            cm.handle_datagram(&bad, cpu(), Instant::now()),
747            Err(RpcError::BadInterface(_))
748        ));
749        assert!(matches!(
750            cm.handle_datagram(&[1, 2, 3], cpu(), Instant::now()),
751            Err(RpcError::TooShort { .. })
752        ));
753    }
754
755    #[test]
756    fn reconnect_updates_controller_addr() {
757        let mut cm = cm();
758        let now = Instant::now();
759        let o = cm.handle_datagram(&pdu("connect_req"), cpu(), now).unwrap();
760        assert_eq!(o.notify, vec![(ArState::Connected, None)]);
761        cm.handle_datagram(&pdu("write_req"), cpu(), now).unwrap();
762        cm.handle_datagram(&pdu("prmend_req"), cpu(), now).unwrap();
763        let o = cm
764            .handle_datagram(&pdu("appready_res"), cpu_cm(), now)
765            .unwrap();
766        assert_eq!(o.notify, vec![(ArState::Data, None)]);
767        assert_eq!(cm.state(), ArState::Data);
768
769        // Same AR (ARUUID unchanged), new SessionKey, from a new source port and a
770        // fresh RPC call (new seq_num — the bench also gets a new activity UUID;
771        // seq_num alone is enough here to not collide with the cached responses of
772        // the exchange above, which used 0/1/2 on this same activity) — as the CPU
773        // does on the bench after it aborts and re-Identifies. ARBlockReq starts at
774        // PDU offset 108 (ARUUID, 16 bytes), so SessionKey is at [124..126].
775        let mut reconnect = pdu("connect_req");
776        reconnect[64] = 3; // seq_num (LE low byte): 0 -> 3, a new RPC call
777        reconnect[124..126].copy_from_slice(&5u16.to_be_bytes());
778        let reconnect_from = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(172, 16, 2, 100)), 54800);
779        let o = cm.handle_datagram(&reconnect, reconnect_from, now).unwrap();
780        assert_eq!(
781            o.notify,
782            vec![
783                (ArState::Idle, Some(AbortReason::ControllerReconnect)),
784                (ArState::Connected, None),
785            ]
786        );
787        assert_eq!(o.send.len(), 1);
788        let (n, _) =
789            crate::rpc::NdrResponse::parse(&o.send[0].bytes[80..], crate::rpc::Drep::BIG).unwrap();
790        assert!(PnioStatus(n.status).is_ok());
791
792        // A PrmEnd on the reconnected AR (again a fresh RPC call, not a retransmit of
793        // the first one already cached above) must place the ApplicationReady call
794        // to the controller address captured by the takeover. The reconnect's source
795        // IP matches the original controller's, so the address itself is unchanged
796        // here — but this exercises the takeover code path (not the stale
797        // `was_idle` guard it replaces) that sets `controller_addr`.
798        let mut prmend = pdu("prmend_req");
799        prmend[64] = 4; // seq_num (LE low byte): 2 -> 4, a new RPC call
800                        // A real controller's PrmEnd carries the SessionKey it just negotiated (5),
801                        // not the golden capture's original one (2); SessionKey is at the same
802                        // [124..126] offset as in ARBlockReq (see the reconnect above).
803        prmend[124..126].copy_from_slice(&5u16.to_be_bytes());
804        let o = cm.handle_datagram(&prmend, cpu(), now).unwrap();
805        assert_eq!(o.send[1].to, cpu_cm());
806        // `call_seq` is scoped to the device's fixed `activity_seed`, not to the AR,
807        // so it must keep counting up across the reconnect rather than restarting at
808        // 0 (which would resend the RPC-CL pair `(activity_seed, 0)` the controller
809        // already completed for the first AR). The call is therefore identical to
810        // the golden ApplicationReady request everywhere except `seq_num` (bytes
811        // [64..68], big-endian on our own outgoing calls, which is 1, not 0) and
812        // SessionKey (bytes [124..126]): the ApplicationReady call is built from the
813        // AR's own context — the reconnected session (5) — not from the PrmEnd
814        // request that triggered it.
815        let mut expected = pdu("appready_req");
816        expected[124..126].copy_from_slice(&5u16.to_be_bytes());
817        assert_eq!(o.send[1].bytes[..64], expected[..64]);
818        assert_eq!(o.send[1].bytes[68..124], expected[68..124]);
819        assert_eq!(o.send[1].bytes[126..], expected[126..]);
820        assert_eq!(
821            u32::from_be_bytes(o.send[1].bytes[64..68].try_into().unwrap()),
822            1
823        );
824    }
825
826    #[test]
827    fn tick_resends_app_ready_to_controller() {
828        let mut cm = cm();
829        let now = Instant::now();
830        cm.handle_datagram(&pdu("connect_req"), cpu(), now).unwrap();
831        cm.handle_datagram(&pdu("prmend_req"), cpu(), now).unwrap();
832        let o =
833            cm.tick(now + crate::cm::ar::APP_READY_TIMEOUT + std::time::Duration::from_millis(1));
834        assert_eq!(o.send[0].bytes, pdu("appready_req"));
835        assert_eq!(o.send[0].to, cpu_cm());
836    }
837
838    // -----------------------------------------------------------------------
839    // Read/ReadImplicit (I&M), against the separate p-net alarm/I&M capture
840    // (`docs/alarm-golden-frames.md`, captured 2026-08-30 — a different session
841    // than the cm-golden capture above, hence its own `ar_uuid`).
842    // -----------------------------------------------------------------------
843
844    fn pnet_im0() -> Im0 {
845        Im0 {
846            order_id: "12345 Abcdefghijk".into(),
847            serial_number: "007".into(),
848            hardware_revision: 3,
849            software_revision: SwRevision {
850                prefix: 'V',
851                functional: 0,
852                bug_fix: 2,
853                internal: 0,
854            },
855            revision_counter: 0,
856            profile_id: 0x1234,
857            profile_specific_type: 0x5678,
858        }
859    }
860
861    /// Brings an AR up to `Data` and re-tags it with the alarm capture's `ar_uuid`.
862    ///
863    /// The AR established from the cm-golden capture carries `ar_uuid` e5e1aecc-...;
864    /// the alarm capture's Read requests carry ef796d60-... (a different p-net
865    /// session). Re-tagging the request PDUs aligns the two so the Read is answered
866    /// rather than refused as foreign. Only the PNIO blocks' own `ar_uuid` fields
867    /// (ARBlockReq, each Write record, PrmEnd's ControlBlock) carry this value — the
868    /// RPC header's object/interface/activity UUIDs are unrelated and untouched, and
869    /// `appready_res` carries no `ar_uuid` at all.
870    fn cm_in_data_with_the_alarm_capture_ar(now: Instant) -> Cm {
871        let mut cm = cm();
872        let old = Uuid::parse_str("e5e1aecc-b133-4b4d-b187-cc68b0211ed2").unwrap();
873        let new = Uuid::parse_str("ef796d60-ef2b-9946-b39e-8531f5b7f966").unwrap();
874        let retag = |name: &str| retag_ar_uuid(pdu(name), old, new);
875
876        cm.handle_datagram(&retag("connect_req"), cpu(), now)
877            .unwrap();
878        cm.handle_datagram(&retag("write_req"), cpu(), now).unwrap();
879        let o = cm
880            .handle_datagram(&retag("prmend_req"), cpu(), now)
881            .unwrap();
882        assert_eq!(o.send[1].to, cpu_cm());
883        let o = cm
884            .handle_datagram(&pdu("appready_res"), cpu_cm(), now)
885            .unwrap();
886        assert_eq!(o.notify, vec![(ArState::Data, None)]);
887        assert_eq!(cm.state(), ArState::Data);
888        cm.set_im(pnet_im0(), crate::im::ImStore::new());
889        cm
890    }
891
892    #[test]
893    fn read_response_matches_the_pnet_im0_golden_byte_exact() {
894        let now = Instant::now();
895        let mut cm = cm_in_data_with_the_alarm_capture_ar(now);
896        let o = cm
897            .handle_datagram(&golden_alarm("im0_read_req")[RPC_OFF..], cpu(), now)
898            .unwrap();
899        assert_eq!(o.send.len(), 1);
900        assert_eq!(o.send[0].bytes, golden_alarm("im0_read_res")[RPC_OFF..]);
901        assert_eq!(o.send[0].to, cpu());
902    }
903
904    /// The capture reads I&M0 on the interface submodule (slot 0, subslot 0x8000)
905    /// too, and p-net answers it with the very same record — `IM_Supported = 0x000E`
906    /// included, not a zeroed mask.
907    #[test]
908    fn read_response_on_the_interface_submodule_matches_the_golden_byte_exact() {
909        let now = Instant::now();
910        let mut cm = cm_in_data_with_the_alarm_capture_ar(now);
911        let o = cm
912            .handle_datagram(&golden_alarm("im0_read_req_if")[RPC_OFF..], cpu(), now)
913            .unwrap();
914        assert_eq!(o.send.len(), 1);
915        assert_eq!(o.send[0].bytes, golden_alarm("im0_read_res_if")[RPC_OFF..]);
916        assert_eq!(o.send[0].to, cpu());
917    }
918
919    #[test]
920    fn read_with_a_foreign_ar_uuid_is_refused() {
921        let mut cm = cm();
922        let now = Instant::now();
923        cm.handle_datagram(&pdu("connect_req"), cpu(), now).unwrap();
924        cm.handle_datagram(&pdu("write_req"), cpu(), now).unwrap();
925        cm.handle_datagram(&pdu("prmend_req"), cpu(), now).unwrap();
926        cm.handle_datagram(&pdu("appready_res"), cpu_cm(), now)
927            .unwrap();
928        assert_eq!(cm.state(), ArState::Data);
929
930        // Unpatched: the alarm capture's `ar_uuid` (ef796d60-...) is genuinely
931        // foreign to the AR just established (e5e1aecc-...).
932        let o = cm
933            .handle_datagram(&golden_alarm("im0_read_req")[RPC_OFF..], cpu(), now)
934            .unwrap();
935        let (n, blocks) =
936            crate::rpc::NdrResponse::parse(&o.send[0].bytes[80..], crate::rpc::Drep::BIG).unwrap();
937        assert_eq!(PnioStatus(n.status), PnioStatus::read_wrong_ar());
938        assert!(blocks.is_empty());
939    }
940
941    #[test]
942    fn read_implicit_answers_ok_from_idle_without_an_ar() {
943        let mut cm = cm();
944        assert_eq!(cm.state(), ArState::Idle);
945        let mut req = golden_alarm("im0_read_req")[RPC_OFF..].to_vec();
946        req[68] = 5; // opnum: Read (2) -> ReadImplicit (5), LE low byte
947        let o = cm.handle_datagram(&req, cpu(), Instant::now()).unwrap();
948        assert_eq!(o.send.len(), 1);
949        let h = crate::rpc::RpcHeader::parse(&o.send[0].bytes).unwrap();
950        assert_eq!(h.opnum, 5);
951        let (n, blocks) =
952            crate::rpc::NdrResponse::parse(&o.send[0].bytes[80..], crate::rpc::Drep::BIG).unwrap();
953        assert!(PnioStatus(n.status).is_ok());
954        assert!(!blocks.is_empty());
955        assert_eq!(cm.state(), ArState::Idle);
956    }
957}