Skip to main content

bacnet_server/server/
discovery.rs

1//! Discovery rate-limiting, duplicate suppression, and directed response handling.
2//!
3//! Controls response amplification for unconfirmed discovery services (Who-Is
4//! and Who-Has) per ASHRAE 135-2020 Clauses 16.9 and 16.10.
5
6use std::collections::HashMap;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::{Arc, Mutex};
9use std::time::{Duration, Instant};
10
11use bytes::BytesMut;
12
13use bacnet_encoding::apdu::{encode_apdu, Apdu, UnconfirmedRequest as UnconfirmedRequestPdu};
14use bacnet_encoding::npdu::NpduAddress;
15use bacnet_network::layer::{NetworkLayer, ReceivedApdu};
16use bacnet_objects::database::ObjectDatabase;
17use bacnet_services::who_has::{WhoHasObject, WhoHasRequest};
18use bacnet_services::who_is::{DeviceInstanceRange, IAmRequest, WhoIsRequest};
19use bacnet_transport::port::TransportPort;
20use bacnet_types::enums::{ErrorClass, ErrorCode, NetworkPriority, UnconfirmedServiceChoice};
21use bacnet_types::error::Error;
22use bacnet_types::primitives::ObjectIdentifier;
23use bacnet_types::MacAddr;
24use tokio::sync::RwLock;
25
26use super::{BACnetServer, CommState, ServerConfig};
27
28/// Configuration policy for discovery rate limiting and duplicate suppression.
29#[derive(Debug, Clone)]
30pub struct DiscoveryPolicy {
31    /// Maximum discovery responses emitted globally per second (default 64).
32    pub max_responses_per_sec_global: u32,
33    /// Maximum discovery response bytes emitted globally per second (default 65,536).
34    pub max_bytes_per_sec_global: usize,
35    /// Maximum discovery responses emitted per source per second (default 16).
36    pub max_responses_per_sec_per_source: u32,
37    /// Maximum discovery response bytes emitted per source per second (default 16,384).
38    pub max_bytes_per_sec_per_source: usize,
39    /// Maximum burst capacity for global discovery responses (default 64).
40    pub global_burst_capacity: u32,
41    /// Maximum burst capacity for per-source discovery responses (default 16).
42    pub source_burst_capacity: u32,
43    /// Headroom reserved exclusively for configured reserved sources (default 8).
44    pub reserved_capacity: u32,
45    /// Sources permitted to consume from reserved capacity.
46    pub reserved_sources: Vec<MacAddr>,
47    /// Coalescing window for duplicate discovery request suppression (default 200ms).
48    pub coalesce_window: Duration,
49    /// Prefer directed unicast responses over network broadcast (default true).
50    pub prefer_directed_responses: bool,
51    /// Maximum number of distinct discovery sources tracked concurrently (default 256).
52    pub max_tracked_sources: usize,
53}
54
55impl Default for DiscoveryPolicy {
56    fn default() -> Self {
57        Self {
58            max_responses_per_sec_global: 64,
59            max_bytes_per_sec_global: 65_536,
60            max_responses_per_sec_per_source: 16,
61            max_bytes_per_sec_per_source: 16_384,
62            global_burst_capacity: 64,
63            source_burst_capacity: 16,
64            reserved_capacity: 8,
65            reserved_sources: Vec::new(),
66            coalesce_window: Duration::from_millis(200),
67            prefer_directed_responses: true,
68            max_tracked_sources: 256,
69        }
70    }
71}
72
73impl DiscoveryPolicy {
74    /// Create an unlimited discovery policy with all rate limits disabled.
75    pub fn unlimited() -> Self {
76        Self {
77            max_responses_per_sec_global: u32::MAX,
78            max_bytes_per_sec_global: usize::MAX,
79            max_responses_per_sec_per_source: u32::MAX,
80            max_bytes_per_sec_per_source: usize::MAX,
81            global_burst_capacity: u32::MAX,
82            source_burst_capacity: u32::MAX,
83            reserved_capacity: 0,
84            coalesce_window: Duration::ZERO,
85            prefer_directed_responses: false,
86            ..Default::default()
87        }
88    }
89
90    /// Return a sanitized copy with valid burst and capacity bounds.
91    pub fn sanitized(&self) -> Self {
92        let mut policy = self.clone();
93        if (1..u32::MAX).contains(&policy.max_responses_per_sec_global) {
94            policy.global_burst_capacity = policy.global_burst_capacity.max(1);
95        }
96        if (1..u32::MAX).contains(&policy.max_responses_per_sec_per_source) {
97            policy.source_burst_capacity = policy.source_burst_capacity.max(1);
98        }
99        policy.reserved_capacity = policy.reserved_capacity.min(policy.global_burst_capacity);
100        policy.max_tracked_sources = policy.max_tracked_sources.max(1);
101        policy
102    }
103}
104
105/// Operational counters for discovery requests and responses.
106///
107/// Non-exhaustive, so a counter can be added without breaking callers: read
108/// it from [`BACnetServer::discovery_counters`](crate::server::BACnetServer::discovery_counters)
109/// and its fields rather than building one.
110#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
111#[non_exhaustive]
112pub struct DiscoveryCounters {
113    /// Total Who-Is requests received.
114    pub who_is_received: u64,
115    /// Total Who-Has requests received.
116    pub who_has_received: u64,
117    /// Total I-Am responses sent.
118    pub i_am_sent: u64,
119    /// Total I-Have responses sent.
120    pub i_have_sent: u64,
121    /// Total response bytes sent for discovery.
122    pub response_bytes_sent: u64,
123    /// Total duplicate or cached requests coalesced without sending responses.
124    pub requests_coalesced: u64,
125    /// Responses throttled by per-source rate or burst limits.
126    pub responses_throttled_source: u64,
127    /// Responses throttled by global rate or reserved capacity limits.
128    pub responses_throttled_global: u64,
129    /// Total directed unicast discovery responses sent.
130    pub directed_responses_sent: u64,
131    /// Who-Is and Who-Has requests dropped because they didn't decode, such
132    /// as one with a single limit or octets after its last member. They are
133    /// dropped before any other work and count in neither received total.
134    pub malformed_dropped: u64,
135}
136
137#[derive(Debug, Default)]
138pub(crate) struct AtomicDiscoveryCounters {
139    pub(crate) who_is_received: AtomicU64,
140    pub(crate) who_has_received: AtomicU64,
141    pub(crate) i_am_sent: AtomicU64,
142    pub(crate) i_have_sent: AtomicU64,
143    pub(crate) response_bytes_sent: AtomicU64,
144    pub(crate) requests_coalesced: AtomicU64,
145    pub(crate) responses_throttled_source: AtomicU64,
146    pub(crate) responses_throttled_global: AtomicU64,
147    pub(crate) directed_responses_sent: AtomicU64,
148    pub(crate) malformed_dropped: AtomicU64,
149}
150
151impl AtomicDiscoveryCounters {
152    pub(crate) fn snapshot(&self) -> DiscoveryCounters {
153        DiscoveryCounters {
154            who_is_received: self.who_is_received.load(Ordering::Relaxed),
155            who_has_received: self.who_has_received.load(Ordering::Relaxed),
156            i_am_sent: self.i_am_sent.load(Ordering::Relaxed),
157            i_have_sent: self.i_have_sent.load(Ordering::Relaxed),
158            response_bytes_sent: self.response_bytes_sent.load(Ordering::Relaxed),
159            requests_coalesced: self.requests_coalesced.load(Ordering::Relaxed),
160            responses_throttled_source: self.responses_throttled_source.load(Ordering::Relaxed),
161            responses_throttled_global: self.responses_throttled_global.load(Ordering::Relaxed),
162            directed_responses_sent: self.directed_responses_sent.load(Ordering::Relaxed),
163            malformed_dropped: self.malformed_dropped.load(Ordering::Relaxed),
164        }
165    }
166
167    #[inline]
168    pub(crate) fn inc(&self, c: &AtomicU64) {
169        c.fetch_add(1, Ordering::Relaxed);
170    }
171
172    pub(crate) fn record_sent(&self, bytes: usize, directed: bool, is_who_is: bool) {
173        if is_who_is {
174            self.i_am_sent.fetch_add(1, Ordering::Relaxed);
175        } else {
176            self.i_have_sent.fetch_add(1, Ordering::Relaxed);
177        }
178        self.response_bytes_sent
179            .fetch_add(bytes as u64, Ordering::Relaxed);
180        if directed {
181            self.directed_responses_sent.fetch_add(1, Ordering::Relaxed);
182        }
183    }
184}
185
186/// Target of a Who-Has query.
187#[derive(Debug, Clone, PartialEq, Eq, Hash)]
188pub(crate) enum WhoHasTarget {
189    Id(ObjectIdentifier),
190    Name(String),
191}
192
193/// Network-aware identity of a discovery requester.
194#[derive(Debug, Clone, PartialEq, Eq, Hash)]
195pub(crate) struct SourceKey {
196    pub(crate) network: u16,
197    pub(crate) mac: MacAddr,
198}
199
200impl SourceKey {
201    pub(crate) fn from_parts(source_mac: &MacAddr, source_network: Option<&NpduAddress>) -> Self {
202        if let Some(net) = source_network {
203            if (1..=0xFFFE).contains(&net.network) && !net.mac_address.is_empty() {
204                return Self {
205                    network: net.network,
206                    mac: MacAddr::from_slice(&net.mac_address),
207                };
208            }
209        }
210        Self {
211            network: 0,
212            mac: source_mac.clone(),
213        }
214    }
215
216    pub(crate) fn from_received(received: &ReceivedApdu) -> Self {
217        Self::from_parts(&received.source_mac, received.source_network.as_ref())
218    }
219}
220
221#[derive(Debug)]
222struct SourceState {
223    tokens: f64,
224    byte_tokens: f64,
225    last_refill: Instant,
226    last_activity: Instant,
227    last_who_is: Option<Instant>,
228    last_who_has: HashMap<WhoHasTarget, Instant>,
229}
230
231fn refill_bucket(
232    tokens: &mut f64,
233    byte_tokens: &mut f64,
234    last_refill: &mut Instant,
235    rate: u32,
236    byte_rate: usize,
237    burst: u32,
238    now: Instant,
239) {
240    let elapsed = now.saturating_duration_since(*last_refill).as_secs_f64();
241    if elapsed > 0.0 {
242        *tokens = if rate == u32::MAX {
243            burst as f64
244        } else {
245            (*tokens + elapsed * (rate as f64)).min(burst as f64)
246        };
247        *byte_tokens = if byte_rate == usize::MAX {
248            byte_rate as f64
249        } else {
250            (*byte_tokens + elapsed * (byte_rate as f64)).min(byte_rate as f64)
251        };
252        *last_refill = now;
253    }
254}
255
256impl SourceState {
257    fn refill(&mut self, policy: &DiscoveryPolicy, now: Instant) {
258        refill_bucket(
259            &mut self.tokens,
260            &mut self.byte_tokens,
261            &mut self.last_refill,
262            policy.max_responses_per_sec_per_source,
263            policy.max_bytes_per_sec_per_source,
264            policy.source_burst_capacity,
265            now,
266        );
267    }
268
269    fn has_capacity(&self, policy: &DiscoveryPolicy, bytes: usize) -> bool {
270        (policy.max_responses_per_sec_per_source == u32::MAX || self.tokens >= 1.0)
271            && (policy.max_bytes_per_sec_per_source == usize::MAX
272                || self.byte_tokens >= bytes as f64)
273    }
274
275    fn deduct(&mut self, policy: &DiscoveryPolicy, bytes: usize) {
276        if policy.max_responses_per_sec_per_source != u32::MAX {
277            self.tokens -= 1.0;
278        }
279        if policy.max_bytes_per_sec_per_source != usize::MAX {
280            self.byte_tokens -= bytes as f64;
281        }
282    }
283}
284
285#[derive(Debug)]
286struct DiscoveryState {
287    global_tokens: f64,
288    global_byte_tokens: f64,
289    global_last_refill: Instant,
290    last_broadcast_who_is: Option<Instant>,
291    last_broadcast_who_has: HashMap<WhoHasTarget, Instant>,
292    negative_who_has: HashMap<WhoHasTarget, Instant>,
293    sources: HashMap<SourceKey, SourceState>,
294}
295
296impl DiscoveryState {
297    fn refill_global(&mut self, policy: &DiscoveryPolicy, now: Instant) {
298        refill_bucket(
299            &mut self.global_tokens,
300            &mut self.global_byte_tokens,
301            &mut self.global_last_refill,
302            policy.max_responses_per_sec_global,
303            policy.max_bytes_per_sec_global,
304            policy.global_burst_capacity,
305            now,
306        );
307    }
308
309    fn has_global_capacity(
310        &self,
311        policy: &DiscoveryPolicy,
312        is_reserved: bool,
313        bytes: usize,
314    ) -> bool {
315        let count_ok = policy.max_responses_per_sec_global == u32::MAX || {
316            let req = if is_reserved {
317                1.0
318            } else {
319                1.0 + policy.reserved_capacity as f64
320            };
321            self.global_tokens >= req
322        };
323        count_ok
324            && (policy.max_bytes_per_sec_global == usize::MAX
325                || self.global_byte_tokens >= bytes as f64)
326    }
327
328    fn deduct_global(&mut self, policy: &DiscoveryPolicy, bytes: usize) {
329        if policy.max_responses_per_sec_global != u32::MAX {
330            self.global_tokens -= 1.0;
331        }
332        if policy.max_bytes_per_sec_global != usize::MAX {
333            self.global_byte_tokens -= bytes as f64;
334        }
335    }
336
337    fn get_or_create_source_mut(
338        &mut self,
339        policy: &DiscoveryPolicy,
340        key: &SourceKey,
341        now: Instant,
342    ) -> &mut SourceState {
343        if !self.sources.contains_key(key) {
344            ensure_source_capacity(self, policy, now);
345        }
346        let src = self
347            .sources
348            .entry(key.clone())
349            .or_insert_with(|| SourceState {
350                tokens: policy.source_burst_capacity as f64,
351                byte_tokens: policy.max_bytes_per_sec_per_source as f64,
352                last_refill: now,
353                last_activity: now,
354                last_who_is: None,
355                last_who_has: HashMap::new(),
356            });
357        src.refill(policy, now);
358        src
359    }
360
361    fn is_coalesced_who_has(
362        &self,
363        policy: &DiscoveryPolicy,
364        target: &WhoHasTarget,
365        key: &SourceKey,
366        is_broadcast: bool,
367        now: Instant,
368    ) -> bool {
369        if policy.coalesce_window.is_zero() {
370            return false;
371        }
372        let is_recent = |ts: &Instant| now.saturating_duration_since(*ts) < policy.coalesce_window;
373        self.negative_who_has.get(target).is_some_and(is_recent)
374            || (is_broadcast
375                && self
376                    .last_broadcast_who_has
377                    .get(target)
378                    .is_some_and(is_recent))
379            || self
380                .sources
381                .get(key)
382                .and_then(|s| s.last_who_has.get(target))
383                .is_some_and(is_recent)
384    }
385}
386
387#[derive(Debug)]
388struct AtomicDeviceInstance(AtomicU64);
389
390impl AtomicDeviceInstance {
391    const PRESENT_BIT: u64 = 0x1_0000_0000;
392
393    fn new(opt: Option<u32>) -> Self {
394        Self(AtomicU64::new(
395            opt.map_or(0, |i| Self::PRESENT_BIT | (i as u64)),
396        ))
397    }
398
399    fn load(&self, order: Ordering) -> Option<u32> {
400        let val = self.0.load(order);
401        (val & Self::PRESENT_BIT != 0).then_some(val as u32)
402    }
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406pub(crate) enum PreCheckDecision {
407    Admit,
408    OutOfRange,
409    Coalesced,
410    ThrottledSource,
411    ThrottledGlobal,
412    DecodeError,
413}
414
415/// Thread-safe rate limiter, duplicate coalescer, and negative query cache.
416#[derive(Debug)]
417pub(crate) struct DiscoveryLimiter {
418    policy: DiscoveryPolicy,
419    counters: AtomicDiscoveryCounters,
420    device_instance: AtomicDeviceInstance,
421    state: Mutex<DiscoveryState>,
422}
423
424impl DiscoveryLimiter {
425    /// Every throttle and coalescing window here is measured on
426    /// [`runtime_clock::now`](super::runtime_clock::now), as its callers'
427    /// instants are.
428    pub(crate) fn new(policy: DiscoveryPolicy, device_instance: Option<u32>) -> Self {
429        let now = super::runtime_clock::now();
430        let state = DiscoveryState {
431            global_tokens: policy.global_burst_capacity as f64,
432            global_byte_tokens: policy.max_bytes_per_sec_global as f64,
433            global_last_refill: now,
434            last_broadcast_who_is: None,
435            last_broadcast_who_has: HashMap::new(),
436            negative_who_has: HashMap::new(),
437            sources: HashMap::new(),
438        };
439        Self {
440            policy,
441            counters: AtomicDiscoveryCounters::default(),
442            device_instance: AtomicDeviceInstance::new(device_instance),
443            state: Mutex::new(state),
444        }
445    }
446
447    pub(crate) fn counters(&self) -> DiscoveryCounters {
448        self.counters.snapshot()
449    }
450
451    #[allow(dead_code)]
452    pub(crate) fn policy(&self) -> &DiscoveryPolicy {
453        &self.policy
454    }
455
456    #[cfg(test)]
457    pub(crate) fn negative_who_has_count(&self) -> usize {
458        self.state.lock().unwrap().negative_who_has.len()
459    }
460
461    #[cfg(test)]
462    pub(crate) fn source_byte_tokens(
463        &self,
464        source_mac: &MacAddr,
465        source_network: Option<&NpduAddress>,
466    ) -> Option<f64> {
467        let key = SourceKey::from_parts(source_mac, source_network);
468        self.state
469            .lock()
470            .unwrap()
471            .sources
472            .get(&key)
473            .map(|s| s.byte_tokens)
474    }
475
476    fn check_instance_in_range(&self, range: Option<DeviceInstanceRange>) -> bool {
477        self.device_instance
478            .load(Ordering::Acquire)
479            .is_some_and(|instance| range.is_none_or(|range| range.contains(instance)))
480    }
481
482    pub(crate) fn pre_check_who_is(
483        &self,
484        req_bytes: &[u8],
485        received: &ReceivedApdu,
486        now: Instant,
487    ) -> PreCheckDecision {
488        // RB-07 compat mode: provenance threaded here for RB-09, no decision.
489        let _ = received.provenance;
490        let who_is = match WhoIsRequest::decode(req_bytes) {
491            Ok(w) => w,
492            Err(_) => {
493                self.counters.inc(&self.counters.malformed_dropped);
494                return PreCheckDecision::DecodeError;
495            }
496        };
497
498        self.counters.inc(&self.counters.who_is_received);
499
500        if !self.check_instance_in_range(who_is.range) {
501            return PreCheckDecision::OutOfRange;
502        }
503
504        let is_broadcast = received.is_group || received.link_layer_group;
505        let source_key = SourceKey::from_received(received);
506        let mut state = self.state.lock().unwrap();
507
508        // 1. Coalescing check
509        if !self.policy.coalesce_window.is_zero() {
510            let is_recent =
511                |ts: &Instant| now.saturating_duration_since(*ts) < self.policy.coalesce_window;
512            let bcast = is_broadcast && state.last_broadcast_who_is.as_ref().is_some_and(is_recent);
513            let src = state
514                .sources
515                .get(&source_key)
516                .and_then(|s| s.last_who_is.as_ref())
517                .is_some_and(is_recent);
518            if bcast || src {
519                self.counters.inc(&self.counters.requests_coalesced);
520                return PreCheckDecision::Coalesced;
521            }
522        }
523
524        // 2. Refill & Rate limits
525        state.refill_global(&self.policy, now);
526        let is_reserved = is_source_reserved(&self.policy, &source_key, received);
527        const ESTIMATED_I_AM_BYTES: usize = 64;
528
529        if !state.has_global_capacity(&self.policy, is_reserved, ESTIMATED_I_AM_BYTES) {
530            self.counters.inc(&self.counters.responses_throttled_global);
531            return PreCheckDecision::ThrottledGlobal;
532        }
533
534        let src = state.get_or_create_source_mut(&self.policy, &source_key, now);
535        if !src.has_capacity(&self.policy, ESTIMATED_I_AM_BYTES) {
536            self.counters.inc(&self.counters.responses_throttled_source);
537            return PreCheckDecision::ThrottledSource;
538        }
539
540        src.deduct(&self.policy, ESTIMATED_I_AM_BYTES);
541        src.last_activity = now;
542        src.last_who_is = Some(now);
543        state.deduct_global(&self.policy, ESTIMATED_I_AM_BYTES);
544        if is_broadcast && !self.policy.prefer_directed_responses {
545            state.last_broadcast_who_is = Some(now);
546        }
547
548        PreCheckDecision::Admit
549    }
550
551    pub(crate) fn record_i_am_sent(
552        &self,
553        actual_bytes: usize,
554        directed: bool,
555        source_mac: &MacAddr,
556        source_network: Option<&NpduAddress>,
557        now: Instant,
558    ) {
559        self.counters.record_sent(actual_bytes, directed, true);
560
561        const ESTIMATED_I_AM_BYTES: usize = 64;
562        let delta = (actual_bytes as f64) - (ESTIMATED_I_AM_BYTES as f64);
563        let mut state = self.state.lock().unwrap();
564        if delta != 0.0 {
565            if self.policy.max_bytes_per_sec_global != usize::MAX {
566                state.global_byte_tokens = (state.global_byte_tokens - delta).max(0.0);
567            }
568            let key = SourceKey::from_parts(source_mac, source_network);
569            if let Some(src) = state.sources.get_mut(&key) {
570                if self.policy.max_bytes_per_sec_per_source != usize::MAX {
571                    src.byte_tokens = (src.byte_tokens - delta).max(0.0);
572                }
573            }
574        }
575        if !directed {
576            state.last_broadcast_who_is = Some(now);
577        }
578    }
579
580    pub(crate) fn pre_check_who_has(
581        &self,
582        req_bytes: &[u8],
583        received: &ReceivedApdu,
584        now: Instant,
585    ) -> PreCheckDecision {
586        // RB-07 compat mode: provenance threaded here for RB-09, no decision.
587        let _ = received.provenance;
588        let who_has = match WhoHasRequest::decode(req_bytes) {
589            Ok(w) => w,
590            Err(_) => {
591                self.counters.inc(&self.counters.malformed_dropped);
592                return PreCheckDecision::DecodeError;
593            }
594        };
595
596        self.counters.inc(&self.counters.who_has_received);
597
598        if !self.check_instance_in_range(who_has.range) {
599            return PreCheckDecision::OutOfRange;
600        }
601
602        let target = match who_has.object {
603            WhoHasObject::Identifier(oid) => WhoHasTarget::Id(oid),
604            WhoHasObject::Name(name) => WhoHasTarget::Name(name),
605        };
606
607        let is_broadcast = received.is_group || received.link_layer_group;
608        let source_key = SourceKey::from_received(received);
609        let mut state = self.state.lock().unwrap();
610
611        if state.is_coalesced_who_has(&self.policy, &target, &source_key, is_broadcast, now) {
612            self.counters.inc(&self.counters.requests_coalesced);
613            return PreCheckDecision::Coalesced;
614        }
615
616        state.refill_global(&self.policy, now);
617        let is_reserved = is_source_reserved(&self.policy, &source_key, received);
618        const ESTIMATED_I_HAVE_BYTES: usize = 64;
619
620        if !state.has_global_capacity(&self.policy, is_reserved, ESTIMATED_I_HAVE_BYTES) {
621            self.counters.inc(&self.counters.responses_throttled_global);
622            return PreCheckDecision::ThrottledGlobal;
623        }
624
625        let src = state.get_or_create_source_mut(&self.policy, &source_key, now);
626        if !src.has_capacity(&self.policy, ESTIMATED_I_HAVE_BYTES) {
627            self.counters.inc(&self.counters.responses_throttled_source);
628            return PreCheckDecision::ThrottledSource;
629        }
630
631        src.last_activity = now;
632        PreCheckDecision::Admit
633    }
634
635    pub(crate) fn is_negative_who_has(&self, target: &WhoHasTarget, now: Instant) -> bool {
636        if self.policy.coalesce_window.is_zero() {
637            return false;
638        }
639        let state = self.state.lock().unwrap();
640        if let Some(ts) = state.negative_who_has.get(target) {
641            now.saturating_duration_since(*ts) < self.policy.coalesce_window
642        } else {
643            false
644        }
645    }
646
647    pub(crate) fn record_negative_who_has(&self, target: WhoHasTarget, now: Instant) {
648        const MAX_NEGATIVE_WHO_HAS: usize = 512;
649        let mut state = self.state.lock().unwrap();
650        if state.negative_who_has.len() >= MAX_NEGATIVE_WHO_HAS {
651            let window = self.policy.coalesce_window;
652            state
653                .negative_who_has
654                .retain(|_, ts| now.saturating_duration_since(*ts) < window);
655        }
656        while state.negative_who_has.len() >= MAX_NEGATIVE_WHO_HAS {
657            let Some(oldest) = state
658                .negative_who_has
659                .iter()
660                .min_by_key(|(_, ts)| *ts)
661                .map(|(k, _)| k.clone())
662            else {
663                break;
664            };
665            state.negative_who_has.remove(&oldest);
666        }
667        state.negative_who_has.insert(target, now);
668    }
669
670    pub(crate) fn try_consume_who_has_response(
671        &self,
672        target: &WhoHasTarget,
673        received: &ReceivedApdu,
674        response_bytes: usize,
675        now: Instant,
676    ) -> bool {
677        let source_key = SourceKey::from_received(received);
678        let mut state = self.state.lock().unwrap();
679        let is_broadcast = received.is_group || received.link_layer_group;
680
681        if state.is_coalesced_who_has(&self.policy, target, &source_key, is_broadcast, now) {
682            self.counters.inc(&self.counters.requests_coalesced);
683            return false;
684        }
685
686        state.refill_global(&self.policy, now);
687        let is_reserved = is_source_reserved(&self.policy, &source_key, received);
688
689        if !state.has_global_capacity(&self.policy, is_reserved, response_bytes) {
690            self.counters.inc(&self.counters.responses_throttled_global);
691            return false;
692        }
693
694        let src = state.get_or_create_source_mut(&self.policy, &source_key, now);
695        if !src.has_capacity(&self.policy, response_bytes) {
696            self.counters.inc(&self.counters.responses_throttled_source);
697            return false;
698        }
699
700        src.deduct(&self.policy, response_bytes);
701        src.last_activity = now;
702        src.last_who_has.insert(target.clone(), now);
703        state.deduct_global(&self.policy, response_bytes);
704        if is_broadcast && !self.policy.prefer_directed_responses {
705            state.last_broadcast_who_has.insert(target.clone(), now);
706        }
707
708        true
709    }
710
711    pub(crate) fn record_i_have_sent(
712        &self,
713        actual_bytes: usize,
714        directed: bool,
715        target: &WhoHasTarget,
716        _source_mac: &MacAddr,
717        _source_network: Option<&NpduAddress>,
718        now: Instant,
719    ) {
720        self.counters.record_sent(actual_bytes, directed, false);
721
722        if !directed {
723            let mut state = self.state.lock().unwrap();
724            state.last_broadcast_who_has.insert(target.clone(), now);
725        }
726    }
727}
728
729fn is_source_reserved(policy: &DiscoveryPolicy, key: &SourceKey, recv: &ReceivedApdu) -> bool {
730    policy
731        .reserved_sources
732        .iter()
733        .any(|r| r == &key.mac || r == &recv.source_mac)
734}
735
736fn ensure_source_capacity(state: &mut DiscoveryState, policy: &DiscoveryPolicy, now: Instant) {
737    if state.sources.len() < policy.max_tracked_sources {
738        return;
739    }
740    state.sources.retain(|_, src| {
741        now.saturating_duration_since(src.last_activity) < Duration::from_secs(10)
742    });
743    while state.sources.len() >= policy.max_tracked_sources {
744        let Some(key) = state
745            .sources
746            .iter()
747            .min_by_key(|(_, src)| src.last_activity)
748            .map(|(k, _)| k.clone())
749        else {
750            break;
751        };
752        state.sources.remove(&key);
753    }
754}
755
756/// Broadcast this device's I-Am unless DeviceCommunicationControl restricts
757/// initiation. This call announces independently of any received Who-Is;
758/// under DISABLE_INITIATION it sends nothing and fails with
759/// `SERVICES` / `COMMUNICATION_DISABLED`, the code Clause 18.6 keeps for local
760/// work that could not initiate a service for that reason.
761pub(crate) async fn broadcast_i_am_from<T: TransportPort + 'static>(
762    config: &ServerConfig,
763    db: &Arc<RwLock<ObjectDatabase>>,
764    network: &Arc<NetworkLayer<T>>,
765    comm_state: &CommState,
766    limiter: Option<&Arc<DiscoveryLimiter>>,
767) -> Result<(), Error> {
768    let guard = db.read().await;
769    let device_oid =
770        crate::local_device::validate_apdu_declaration(&guard, config.max_apdu_length)?
771            .ok_or_else(|| Error::Encoding("no Device object in database".into()))?;
772
773    // RB-16 alignment: this construction is field-for-field identical to
774    // `bacnet-endpoint`'s `DeviceIdentity::iam_request` built from
775    // `DeviceIdentity::server_config`. The endpoint session I-Am path encodes
776    // the same four fields (object id + max-apdu + segmentation + vendor) so
777    // I-Am vs Device ReadProperty vs role limits agree on one identity.
778    let mut service_buf = BytesMut::new();
779    iam_request_for(device_oid, config).encode(&mut service_buf);
780
781    let mut buf = BytesMut::new();
782    encode_apdu(
783        &mut buf,
784        &Apdu::UnconfirmedRequest(UnconfirmedRequestPdu {
785            service_choice: UnconfirmedServiceChoice::I_AM,
786            service_request: service_buf.freeze(),
787        }),
788    )?;
789
790    drop(guard);
791    if comm_state.initiation_restricted() {
792        return Err(Error::Protocol {
793            class: ErrorClass::SERVICES.to_raw() as u32,
794            code: ErrorCode::COMMUNICATION_DISABLED.to_raw() as u32,
795        });
796    }
797    if let Some(limiter) = limiter {
798        limiter.record_i_am_sent(
799            buf.len(),
800            false,
801            &MacAddr::new(),
802            None,
803            super::runtime_clock::now(),
804        );
805    }
806
807    network
808        .broadcast_apdu(&buf, false, NetworkPriority::NORMAL)
809        .await
810}
811
812/// RB-16 discovery-alignment helper: single I-Am construction shared with the
813/// endpoint identity path.
814///
815/// `bacnet-endpoint`'s `DeviceIdentity::iam_request` (via
816/// `DeviceIdentity::server_config`) builds these same four fields; any drift
817/// here breaks the I-Am ≡ ReadProperty ≡ role-capabilities matrix.
818#[doc(hidden)]
819pub fn iam_request_for(device_oid: ObjectIdentifier, config: &ServerConfig) -> IAmRequest {
820    IAmRequest {
821        object_identifier: device_oid,
822        max_apdu_length: config.max_apdu_length,
823        segmentation_supported: config.segmentation_supported,
824        vendor_id: config.vendor_id,
825    }
826}
827
828impl<T: TransportPort + 'static> BACnetServer<T> {
829    /// Broadcast I-Am through this server's bounded local-send owner.
830    /// Cancelling the waiter leaves admitted work owned until completion or stop.
831    /// While DeviceCommunicationControl restricts initiation nothing is sent
832    /// and this fails with `SERVICES` / `COMMUNICATION_DISABLED` (Clause 16.1).
833    pub async fn broadcast_i_am(&self) -> Result<(), Error> {
834        self.broadcaster
835            .send(Some(Arc::clone(&self.discovery_limiter)))
836            .await
837    }
838}