bacnet_router_core/lib.rs
1//! Portable BACnet router behaviour (Clause 6), for firmware and for the
2//! hosted router of `bacnet-network`.
3//!
4//! The crate is `no_std` and needs no allocator. NPDUs are read in place
5//! from the caller's frame and written into the caller's buffer; the
6//! routing table, the discovery state and the router's output scratch live
7//! in arrays whose size the caller picks with const generics; and time is
8//! an [`Instant`] the caller passes to each call that needs one. It reads
9//! no clock and opens no socket, and nothing on a routing decision formats
10//! text or divides a 64-bit value. None of that storage keeps timers or
11//! defaults of its own, so it all starts as zero bits and can live in
12//! `.bss`.
13//!
14//! [`Instant`] and [`Duration`] are `bacnet-time`'s, in nanoseconds,
15//! re-exported here: the types `bacnet-mstp-core` and `bacnet-bip-core`
16//! take too, so firmware that runs a router between their ports feeds all
17//! three one clock.
18//!
19//! - [`npdu`]: `bacnet-npdu`, re-exported: the network-layer vocabulary
20//! the router speaks. Its borrowed view of an NPDU's control information
21//! is the stack's one NPDU codec, whose encoder refuses an NPDU too long
22//! for its buffer before writing an octet; it also has the message type
23//! codes, the reject reasons, the network priority, the port limits and
24//! the bounded [`Mac`].
25//! - [`message`]: the network-layer messages a router reads and sends:
26//! allocation-free views and writers for their payloads.
27//! - [`table`]: the routing table, sorted by network number, with
28//! learning, the convergence modes, busy and unreachable marks, the
29//! reject hold-down and aging.
30//! - [`discovery`]: which unknown networks to look for, at most once per
31//! cooldown each, in bounded storage.
32//! - [`router`]: the sans-IO engine that joins them: a [`Router`] over a
33//! [`Storage`] takes each frame a port receives, says what it means for
34//! the router's own application, and asks for the sends it calls for,
35//! which the caller polls. It takes the time with every call.
36//! - [`node`]: the receive side of a node that doesn't route: the checks
37//! every arriving frame passes, which the router runs too, and what a
38//! node keeps of what passes. The hosted non-router network layer runs
39//! on it.
40//!
41//! A port is identified by its index, a [`PortId`]; its MAC is a [`Mac`] of
42//! up to [`MAX_MAC_LEN`] octets. To the engine a port is data, so any link
43//! plugs in; two features add the glue for the portable link cores, so
44//! firmware can route between them with no allocator:
45//!
46//! - `bip`: B/IP ports over `bacnet-bip-core`'s port driver, in normal or
47//! foreign-device mode, or as a BBMD that answers management requests
48//! from its tables (module `bip`).
49//! - `mstp`: MS/TP ports over `bacnet-mstp-core`'s master core, with
50//! extended frames (module `mstp`).
51//!
52//! Both hand the router each NPDU their link delivers as an `Arrived`,
53//! whose frame borrows its link source, and carry out the router's sends on
54//! their link. Each module documents its wiring, and the clauses its tests
55//! cover. Each feature also re-exports its link core (`bip_core`,
56//! `mstp_core`, and through them their codecs), so a router image depends
57//! on this crate alone, and [`RouterIn`] names a router over borrowed
58//! storage without repeating its sizes.
59//!
60//! The router counts what it drops, sends and is refused as the link cores
61//! count: [`Router::take_counters`] drains `u32` counts that saturate
62//! between takes, for the caller to add to totals as wide as it needs, and
63//! [`Router::counters`] looks without taking.
64//!
65//! The types that may grow are `#[non_exhaustive]`. Start the
66//! [`Options`](router::Options) from `STANDARD` (or `HOSTED`, the hosted
67//! router's, with its 256-route cap) and set the fields to change;
68//! build a [`Route`](table::Route), for comparison, with its `direct` or
69//! `learned`, and counters from `Default`; and give a `match` on an error,
70//! a refusal, a [`Discard`](router::Discard), an
71//! [`Event`](router::Event), a [`Kind`](router::Kind) or another of these
72//! enums a wildcard arm. The enums a host acts on case by case,
73//! [`Action`](router::Action), [`Received`](router::Received) and a node's [`Received`](node::Received), stay
74//! exhaustive, as do the closed sets such as
75//! [`Dest`](router::Dest), [`Group`](router::Group) and
76//! [`Reachability`](table::Reachability). The wire codes,
77//! [`NetworkMessageType`](npdu::NetworkMessageType) and
78//! [`RejectMessageReason`](npdu::RejectMessageReason), are open: any octet
79//! converts,
80//! so a `match` on their constants needs a wildcard arm too.
81//!
82//! # Time
83//!
84//! A `now` earlier than the latest one passed in is taken as that latest
85//! one: time never runs backwards, so a clock that steps back holds the
86//! timers still instead of failing. Here that is the [`Router`]; the table
87//! and discovery keep no clock of their own, and count a `now` before a
88//! stamp as no time passed. The B/IP and MS/TP ports hand their `now` to
89//! their link core, which keeps the same rule, and a BBMD port keeps the
90//! latest time for its tables too.
91//!
92//! # Example
93//!
94//! A router between network 1, a B/IP-like link with six-octet MACs, and
95//! network 2, an MS/TP-like one, passes a request from station 5 on network
96//! 2 on to a node on network 1:
97//!
98//! ```
99//! use bacnet_router_core::npdu::{Address, Mac, Npdu, NpduLimits};
100//! use bacnet_router_core::router::{
101//! Action, Dest, Frame, Groups, Options, Permissive, PortConfig, Received,
102//! };
103//! use bacnet_router_core::{Instant, PortId, Router, Storage};
104//!
105//! let ports = [
106//! PortConfig::new(1, Mac::from_array([192, 0, 2, 1, 0xBA, 0xC0]), NpduLimits::both(1497), Groups::None),
107//! PortConfig::new(2, Mac::from_array([1]), NpduLimits::both(1497), Groups::None),
108//! ];
109//! // Two ports, a table of 8 routes and 8 hold-down records; firmware
110//! // keeps the storage in a static.
111//! let mut storage = Storage::<2, 8, 8>::new();
112//! let mut router = Router::new(&mut storage, &ports, Options::STANDARD, Instant::ZERO)?;
113//!
114//! // A confirmed request for the node at 192.0.2.9 on network 1.
115//! let node = [192, 0, 2, 9, 0xBA, 0xC0];
116//! let mut request = Npdu::apdu(&[0x00, 0x05, 0x01, 0x0C]);
117//! request.destination = Some(Address { network: 1, mac: &node });
118//! request.expecting_reply = true;
119//! let mut frame = [0u8; 32];
120//! let len = request.encode_into(&mut frame)?;
121//!
122//! let arrived = Frame { port: PortId(1), source: &[5], link_group: false, npdu: &frame[..len] };
123//! let received = router.receive(arrived, Instant::from_secs(1), &mut Permissive);
124//! assert_eq!(received, Received::Handled);
125//!
126//! // It goes to the node on port 0, with the station as its source.
127//! let Action::Send(send) = router.poll_action() else {
128//! panic!("a send");
129//! };
130//! assert_eq!((send.port, send.to), (PortId(0), Dest::Unicast(&node[..])));
131//! let relayed = Npdu::decode(send.npdu).expect("an NPDU");
132//! assert_eq!(relayed.source, Some(Address { network: 2, mac: &[5] }));
133//! assert_eq!(relayed.destination, None);
134//! assert_eq!(router.poll_action(), Action::Idle);
135//! # Ok::<(), Box<dyn core::error::Error>>(())
136//! ```
137//!
138//! # Clause 6 coverage
139//!
140//! What the core is answerable for, in our words, with the tests that show
141//! it. First its parts:
142//!
143//! | Obligation | Clause | Test |
144//! | --- | --- | --- |
145//! | The control octet says which NPCI fields follow, and they follow in one fixed order; a zero DNET, a zero or all-ones SNET and an empty SADR make the NPDU invalid | 6.2, 6.2.2, 6.2.2.1 | `bacnet-npdu`'s `tests/view.rs`: `decoded_frames_are_consistent`, `malformations_are_named` |
146//! | A DLEN or SLEN past any data link's address width is told apart from other faults, carrying the DNET and the source a reason-6 reject needs | 6.2.2.2, 6.4.4 | `bacnet-npdu`'s `tests/view.rs`: `over_long_addresses_carry_what_a_reject_needs` |
147//! | Each network-layer message's payload is read whole or not at all, and written in the layout the clause gives it | 6.4 | `tests/message.rs` |
148//! | The table records, for each network it can reach, the port, the next router (none for a directly attached network), and a state that separates a lasting failure from congestion | 6.6.1 | `tests/table.rs` |
149//! | A later claim of the route to a network supersedes the earlier one | 6.6.3.2 | `tests/table_claims.rs` |
150//! | A reject with reason 1 marks a learned route unreachable, and one with reason 2 marks it busy for a while; no other reason changes the table | 6.6.3.5 | `src/table/tests/rejects.rs` (unit tests, as the hold-down records they read are the crate's own) |
151//! | Flow control holds traffic for the networks named, or for every network the sending router serves when none are named, and lets it go again | 6.6.3.6, 6.6.3.7 | `tests/table_busy.rs` |
152//! | A routing-table update replaces or adds a network's row, and Port ID 0 deletes it | 6.4.7, 6.6.3.8 | `tests/table.rs`: `management_updates_never_touch_direct_routes` |
153//! | A router with no route to a network looks for one before it refuses the message | 6.6.3.1 | `tests/discovery.rs` |
154//!
155//! Then the engine, with the hosted router's options:
156//!
157//! | Obligation | Clause | Test |
158//! | --- | --- | --- |
159//! | An address length no link uses draws reason 6 when the NPDU names a network, and nothing when it is a global broadcast | 6.2.2.2, 6.4.4 | `tests/ingress.rs`: `over_long_addresses_reject_with_reason_6` |
160//! | A message for the router's own address on any of its networks goes to its own application, not on a wire | 6.1, Figure 6-12 | `tests/ingress.rs`: `own_address_on_any_port_is_local`, `own_mac_on_a_remote_network_is_routed_on` |
161//! | Only an Unconfirmed-Request may use a broadcast network address | 6.3 | `tests/ingress.rs`: `broadcast_addresses_carry_only_unconfirmed` |
162//! | Missing SNET and SADR are filled in from the arrival network and the link sender; present ones travel unchanged | 6.5.4 | `tests/forward.rs`: `source_is_added_once_and_kept_after` |
163//! | Onto an attached network the destination fields and hop count are dropped, and the frame goes to the DADR, or to every node for DLEN 0 | 6.5.4 | `tests/forward.rs`: `direct_delivery_strips_destination` |
164//! | Toward another router the destination fields stay and the hop count falls by one; a count that would reach zero goes no further | 6.2.3, 6.5.4 | `tests/forward.rs`: `next_router_keeps_destination_and_counts_down`, `hop_count_reaching_zero_is_not_relayed`, `hop_count_reaching_zero_stops_routed_network_messages` |
165//! | A global broadcast reaches every attached network but the one it came from, still global, and the router's own application too; one whose count would reach zero is only delivered | 6.3.2, 6.5.4 | `tests/forward.rs`: `global_broadcast_fans_out_and_delivers` |
166//! | A network message for another network is routed like an APDU, a proprietary one included, vendor ID and all | 6.2.5, 6.5.4 | `tests/forward.rs`: `proprietary_messages_route_opaquely` |
167//! | Lacking a route, the router searches for one and refuses the message with reason 1 | 6.6.3.1 | `tests/traces.rs`: `a_discovery_learning_and_aging` |
168//! | A refusal goes to whoever first sent the refused message, as its source fields say | 6.4.4, 6.5.4 | `tests/reject.rs`: `reject_path_follows_the_origin` |
169//! | A reject that arrives updates the table, for reasons 1 and 2 only, then travels on toward the node it names, whatever its reason | 6.6.3.5 | `tests/reject.rs`: `received_rejects_relay_by_their_destination`, `other_reasons_relay_and_keep_the_route`; `tests/traces.rs`: `c_received_rejects` |
170//! | Traffic for a network on hold draws reason 2 until the hold lapses or is lifted | 6.6.3.6 | `tests/traces.rs`: `b_flow_control` |
171//! | A message type the router doesn't know, sent to it, draws reason 3 | 6.6.3.5 | `tests/control.rs`: `unknown_types_draw_reason_3` |
172//! | Route announcements teach the table and go on to the other ports | 6.6.3.2, 6.6.3.3 | `tests/control.rs`: `i_am_router_learns_then_rebroadcasts`; `tests/traces.rs`: `d_hardened_convergence` |
173//! | A route question about one network is answered when the network lies beyond another port, searched further, with the asker as origin, when the table lacks it, and left alone when it lies beyond the arrival port | 6.6.3.2 | `tests/control.rs`: `scoped_who_is_router_cases` |
174//! | A route question about every network is answered with those beyond the other ports, ascending, but for those failed for good | 6.6.3.2 | `tests/control.rs`: `unscoped_answer_lists_reachable_networks` |
175//! | Flow control covers what its sender serves, named or not, and goes on to the other ports | 6.6.3.6, 6.6.3.7 | `tests/control.rs`: `flow_control_covers_what_the_sender_serves` |
176//! | A table update applies entry by entry and gets an empty answer; a query gets the whole table, over several answers when needed, and changes nothing | 6.4.7, 6.6.3.8, 6.6.3.9 | `tests/init.rs` |
177//! | The network-number messages stay on their link | 6.4.19, 6.4.20 | `tests/control.rs`: `network_number_messages_stay_local` |
178//! | No port sits on network 0, which names none, or 0xFFFF, which can't be the SNET of what it relays | 6.2.2.1 | `tests/engine.rs`: `new_refuses_what_it_cannot_route` |
179//! | At start each port hears its own number, then which networks lie beyond the others | 6.4.20, 6.6.2, 6.6.3.3 | `tests/traces.rs`: `a_discovery_learning_and_aging`, `a_one_port_router_announces_only_its_number` |
180//! | With no point-to-point links, connection messages change nothing | 6.6.3.10, 6.6.3.11 | `tests/control.rs`: `ptp_messages_change_nothing` |
181//! | A message too long for its egress port draws reason 4, a remote broadcast included, and a fan-out leaves that port out | 6.4.4, 6.6.3.5, Table 6-1 | `tests/forward.rs`: `too_long_for_the_egress_port_draws_reason_4`, `a_fan_out_goes_by_the_broadcast_limit` |
182//! | One port's sends leave in network-priority order: a frame's own output, then the router's own messages at Normal priority | 6.5.4 | `tests/congestion.rs`: `one_ports_sends_leave_in_priority_order` |
183//! | The router's own application reaches another network through the table, from the address it selects, and its replies come back to it; with no route it searches first | 6.1, 6.5.4, 6.6.3.1 | `tests/originate.rs` (#1699) |
184//! | The router's own global broadcast leaves by every port as that link's broadcast, still global with a full hop count, from the selected address on each | 6.2.3, 6.3.2, 6.5.3, 6.5.4 | `tests/originate_global.rs` (#1809) |
185//!
186//! With the opt-in [`Congestion`](router::Congestion) policy (#1697):
187//!
188//! | Obligation | Clause | Test |
189//! | --- | --- | --- |
190//! | A port that stays clogged has its networks announced busy on every other port, again while it lasts, and available once it drains | 6.6.3.6, 6.6.3.7, 6.6.4 | `tests/congestion.rs`: `busy_after_clog_time_then_available_under_the_low_water_mark`, `a_lasting_clog_repeats_busy_and_drains_once` |
191//! | Traffic a clogged port's full queue refuses draws reason 2 | 6.6.3.6 | `tests/congestion.rs`: `a_full_queue_on_a_clogged_port_draws_reason_2` |
192//!
193//! The other opt-in hardening of #1698, beyond the clause: with
194//! `next_hop_rejects`, a reject changes a learned route only when its
195//! next router sent it (`tests/hardening.rs`:
196//! `next_hop_rejects_apply_only_from_the_routes_next_router`); flow control
197//! from any other router is counted, as it always was ignored
198//! (`flow_control_from_another_router_is_counted_and_changes_nothing`); and
199//! with `duplicates`, a global broadcast seen again within its time to live
200//! is dropped (`a_looped_copy_within_the_ttl_is_dropped`), and so is a
201//! relayed I-Am-Router, Router-Busy, Router-Available or Who-Is-Router,
202//! which no hop count bounds: around a loop of routers one circulates for
203//! good without it, and with it each router takes the first copy only
204//! (`tests/loops.rs`, #1810, #2020). Without any option, a global broadcast
205//! or Who-Is-Router whose SNET and SADR name the router itself is its own
206//! come back round a loop, and is dropped (`tests/loops.rs`, #2021).
207//!
208//! Rules of this stack beyond the clause, each tested in the same files:
209//! the 18-octet address bound (#1141, #1198); a DADR beside DNET 0xFFFF
210//! (#1379); group DADRs, where only an Unconfirmed-Request for a group of
211//! the delivery port's own goes on, as that port's broadcast (#1504,
212//! #1677); the relay back out the arrival port of a
213//! message for the arrival network (a DLEN 0 broadcast at the port's limit
214//! is delivered and also draws reason 4, as the relay gains source
215//! fields); the RB-09 gate for each protected
216//! message, silent when it refuses; direct routes that no learning, reject
217//! or management write changes; a 30-second hold-down per network and
218//! arrival port on reject-driven changes; hardened convergence, which
219//! waits for a second claim before moving a route to another port; the
220//! route cap; a discovery cooldown, cap and expiry; no reject for a global
221//! broadcast, and none to the router itself.
222//!
223//! # Features
224//!
225//! None by default: the crate then depends only on `bacnet-time` and
226//! `bacnet-npdu`. Neither
227//! feature needs std or an allocator, and every combination builds on
228//! bare-metal targets.
229//!
230//! - `bip`: the B/IP glue, which adds `bacnet-bip-core`, and through it
231//! `bacnet-bip-codec`, re-exported as `bip_core`.
232//! - `mstp`: the MS/TP glue, which adds `bacnet-mstp-core`, re-exported as
233//! `mstp_core`.
234
235#![no_std]
236#![forbid(unsafe_code)]
237#![cfg_attr(docsrs, feature(doc_cfg))]
238#![warn(missing_debug_implementations)]
239#![warn(clippy::must_use_candidate)]
240
241#[cfg(test)]
242extern crate std;
243
244#[cfg(any(feature = "bip", feature = "mstp"))]
245mod arrived;
246#[cfg(feature = "bip")]
247pub mod bip;
248pub mod discovery;
249pub mod message;
250#[cfg(feature = "mstp")]
251pub mod mstp;
252pub mod node;
253pub mod router;
254pub mod table;
255
256#[cfg(any(feature = "bip", feature = "mstp"))]
257pub use arrived::Arrived;
258/// `bacnet-bip-core`, whose node, port and BBMD types the `bip` glue
259/// speaks: a router image names them through this, so its versions can't
260/// drift apart from the glue's. Its codec is [`bip_core::codec`].
261#[cfg(feature = "bip")]
262pub use bacnet_bip_core as bip_core;
263/// `bacnet-mstp-core`, whose master core the `mstp` glue speaks: a router
264/// image names it through this, so its versions can't drift apart from the
265/// glue's. Its codec is [`mstp_core::codec`].
266#[cfg(feature = "mstp")]
267pub use bacnet_mstp_core as mstp_core;
268/// `bacnet-npdu`, the network-layer vocabulary the router speaks: the NPDU
269/// view and encoder, the message types and reject reasons, a reject's
270/// data ([`RejectMessageToNetwork`](npdu::RejectMessageToNetwork)), the
271/// network priority, [`NpduLimits`](npdu::NpduLimits), [`Mac`],
272/// [`MAX_NPDU`] and [`MAX_MAC_LEN`]. A
273/// router image names them through this, so its version can't drift apart
274/// from the router's.
275pub use bacnet_npdu as npdu;
276pub use bacnet_time::{Duration, Instant};
277pub use router::{Router, RouterIn, Storage};
278
279// For the crate's own paths: the public ones are under `npdu`.
280use npdu::{MAX_MAC_LEN, MAX_NPDU, Mac};
281
282/// A router port, by its index in the router's port list.
283///
284/// On the wire, in a routing table's Port ID field, port `n` is `n + 1`:
285/// Port ID 0 asks the receiver to delete a network's rows (Clause 6.4.7),
286/// so it names no port. Index 255 has no wire Port ID.
287#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
288pub struct PortId(pub u8);
289
290impl PortId {
291 /// This port's Port ID in a routing table on the wire, or `None` for
292 /// index 255, which the one-octet field can't name.
293 #[must_use]
294 pub const fn wire_id(self) -> Option<u8> {
295 self.0.checked_add(1)
296 }
297
298 /// The port a wire Port ID names on a router with `port_count` ports:
299 /// `None` for Port ID 0, the delete request, and for a Port ID past the
300 /// last port.
301 #[must_use]
302 pub const fn from_wire_id(id: u8, port_count: usize) -> Option<Self> {
303 match id.checked_sub(1) {
304 Some(index) if (index as usize) < port_count => Some(Self(index)),
305 _ => None,
306 }
307 }
308}
309
310/// Swap `slice[low]` and `slice[high]`.
311///
312/// The caller keeps `low <= high < slice.len()`, which a debug assertion
313/// checks; `low == high` leaves the slice as it is, which a compaction
314/// that has kept everything so far relies on. A release build given
315/// anything else does nothing.
316///
317/// The table and its hold-down records, the searches, the set of networks
318/// one message claims and the output queue shift and compact their arrays
319/// with this, one element at a time, never with `copy_within` or a copy
320/// from one element to another, which compile to a memmove call:
321/// 0.8-1.4 KB of code on a microcontroller. `slice::swap` would link one
322/// too for a large element, such as a route, since it has to allow equal
323/// indices; two borrows of distinct elements can't overlap, so their swap
324/// needs no memmove.
325pub(crate) fn swap_apart<T>(slice: &mut [T], low: usize, high: usize) {
326 debug_assert!(
327 low <= high && high < slice.len(),
328 "swap_apart needs low <= high < len"
329 );
330 if let Some((front, back)) = slice.split_at_mut_checked(high)
331 && let (Some(first), Some(second)) = (front.get_mut(low), back.first_mut())
332 {
333 core::mem::swap(first, second);
334 }
335}
336
337/// The README's example, compiled and run as a doctest.
338#[cfg(doctest)]
339#[doc = include_str!("../README.md")]
340pub struct ReadmeDoctests;