Skip to main content

bacnet_objects/
database.rs

1//! ObjectDatabase — stores and retrieves BACnet objects by identifier.
2
3use std::collections::{HashMap, HashSet};
4use std::sync::Arc;
5
6use bacnet_types::enums::{ErrorClass, ErrorCode, ObjectType};
7use bacnet_types::error::Error;
8use bacnet_types::primitives::ObjectIdentifier;
9
10use crate::clock::{ClockFrame, ClockReader};
11use crate::event_enrollment::EventEnrollmentMonitoredSource;
12use crate::traits::{BACnetObject, MonotonicClock};
13
14/// A collection of BACnet objects, keyed by ObjectIdentifier.
15///
16/// Enforces Object_Name uniqueness within a device.
17/// Maintains secondary indexes for O(1) name lookup and O(1) type lookup.
18pub struct ObjectDatabase {
19    objects: HashMap<ObjectIdentifier, Box<dyn BACnetObject>>,
20    /// Shared Device clock reader. `None` is an explicit clockless database.
21    clock: Option<Arc<dyn ClockReader>>,
22    monotonic_clock: Option<Arc<MonotonicClock>>,
23    /// Device-local EventNotification ordering source for clockless operation.
24    event_sequence_number: u16,
25    /// Reverse index: object name → ObjectIdentifier for uniqueness enforcement.
26    name_index: HashMap<String, ObjectIdentifier>,
27    /// Type index: object type → set of ObjectIdentifiers for fast enumeration.
28    type_index: HashMap<ObjectType, Vec<ObjectIdentifier>>,
29    /// Event Enrollment objects whose private evaluator state must be reset
30    /// before it can be used again.
31    invalid_enrollment_eval_state: HashSet<ObjectIdentifier>,
32    /// Source ownership for custom Event Enrollment objects that implement
33    /// evaluation state but not the optional object-owned source channel.
34    enrollment_eval_sources: HashMap<ObjectIdentifier, EventEnrollmentMonitoredSource>,
35}
36
37/// A non-consuming reservation of the database-local event sequence source.
38#[doc(hidden)]
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct EventSequenceReservation {
41    number: u16,
42}
43
44impl EventSequenceReservation {
45    /// The sequence number selected by this reservation.
46    #[doc(hidden)]
47    pub fn number(self) -> u16 {
48        self.number
49    }
50}
51
52impl Default for ObjectDatabase {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58impl ObjectDatabase {
59    /// Create an empty database.
60    pub fn new() -> Self {
61        Self {
62            objects: HashMap::new(),
63            clock: None,
64            monotonic_clock: None,
65            event_sequence_number: 0,
66            name_index: HashMap::new(),
67            type_index: HashMap::new(),
68            invalid_enrollment_eval_state: HashSet::new(),
69            enrollment_eval_sources: HashMap::new(),
70        }
71    }
72
73    /// Add an object to the database.
74    ///
75    /// Returns `Err` if another object already has the same `object_name()`.
76    /// Replacing an object with the same OID is allowed (the old object is removed).
77    pub fn add(&mut self, mut object: Box<dyn BACnetObject>) -> Result<(), Error> {
78        object.bind_clock_internal(self.clock.clone());
79        object.bind_monotonic_clock_internal(self.monotonic_clock.clone());
80        let oid = object.object_identifier();
81        let name = object.object_name().to_string();
82
83        // Check for name collision with a *different* object
84        if let Some(&existing_oid) = self.name_index.get(&name) {
85            if existing_oid != oid {
86                return Err(Error::Protocol {
87                    class: ErrorClass::OBJECT.to_raw() as u32,
88                    code: ErrorCode::DUPLICATE_NAME.to_raw() as u32,
89                });
90            }
91        }
92
93        // If replacing an existing object, remove its old name from the index
94        // and invalidate state owned by enrollments that monitor it.
95        if let Some(old) = self.objects.get(&oid) {
96            let old_name = old.object_name().to_string();
97            self.name_index.remove(&old_name);
98            self.invalidate_enrollments_monitoring(&oid);
99        }
100
101        self.name_index.insert(name, oid);
102        let is_new = !self.objects.contains_key(&oid);
103        self.enrollment_eval_sources.remove(&oid);
104        self.objects.insert(oid, object);
105        if is_new {
106            self.type_index
107                .entry(oid.object_type())
108                .or_default()
109                .push(oid);
110        }
111        Ok(())
112    }
113
114    /// Look up an object by its name. O(1) via the name index.
115    pub fn find_by_name(&self, name: &str) -> Option<&dyn BACnetObject> {
116        let oid = self.name_index.get(name)?;
117        self.objects.get(oid).map(|o| o.as_ref())
118    }
119
120    /// Check whether `new_name` is available for object `oid`.
121    ///
122    /// Returns `Ok(())` if the name is unused or already belongs to `oid`.
123    /// Returns `Err(DUPLICATE_NAME)` if another object owns the name.
124    pub fn check_name_available(
125        &self,
126        oid: &ObjectIdentifier,
127        new_name: &str,
128    ) -> Result<(), Error> {
129        if let Some(&owner) = self.name_index.get(new_name) {
130            if owner != *oid {
131                return Err(Error::Protocol {
132                    class: ErrorClass::OBJECT.to_raw() as u32,
133                    code: ErrorCode::DUPLICATE_NAME.to_raw() as u32,
134                });
135            }
136        }
137        Ok(())
138    }
139
140    /// Update the name index after a successful Object_Name write.
141    ///
142    /// Call this after `write_property(OBJECT_NAME, …)` succeeds.
143    pub fn update_name_index(&mut self, oid: &ObjectIdentifier) {
144        if let Some(obj) = self.objects.get(oid) {
145            // Remove any old name mapping for this OID
146            self.name_index.retain(|_, v| v != oid);
147            // Insert the current name
148            self.name_index.insert(obj.object_name().to_string(), *oid);
149        }
150    }
151
152    /// Get a shared reference to an object by identifier.
153    pub fn get(&self, oid: &ObjectIdentifier) -> Option<&dyn BACnetObject> {
154        self.objects.get(oid).map(|o| o.as_ref())
155    }
156
157    /// Get a mutable reference to an object by identifier.
158    pub fn get_mut(&mut self, oid: &ObjectIdentifier) -> Option<&mut Box<dyn BACnetObject>> {
159        self.objects.get_mut(oid)
160    }
161
162    /// Whether an Event Enrollment object's private evaluator state requires a
163    /// successful reset before reuse.
164    pub fn enrollment_eval_state_invalidated(&self, oid: &ObjectIdentifier) -> bool {
165        self.invalid_enrollment_eval_state.contains(oid)
166    }
167
168    /// Mark or clear the reset requirement for Event Enrollment private state.
169    pub fn set_enrollment_eval_state_invalidated(
170        &mut self,
171        oid: ObjectIdentifier,
172        invalidated: bool,
173    ) {
174        if invalidated {
175            self.invalid_enrollment_eval_state.insert(oid);
176        } else {
177            self.invalid_enrollment_eval_state.remove(&oid);
178        }
179    }
180
181    /// Return database-owned monitored-source state for a custom Event
182    /// Enrollment object.
183    pub fn enrollment_eval_source(
184        &self,
185        oid: &ObjectIdentifier,
186    ) -> Option<EventEnrollmentMonitoredSource> {
187        self.enrollment_eval_sources.get(oid).copied()
188    }
189
190    /// Store database-owned monitored-source state for a custom Event
191    /// Enrollment object.
192    pub fn set_enrollment_eval_source(
193        &mut self,
194        oid: ObjectIdentifier,
195        source: Option<EventEnrollmentMonitoredSource>,
196    ) {
197        if let Some(source) = source {
198            self.enrollment_eval_sources.insert(oid, source);
199        } else {
200            self.enrollment_eval_sources.remove(&oid);
201        }
202    }
203
204    fn invalidate_enrollments_monitoring(&mut self, monitored_oid: &ObjectIdentifier) {
205        let affected = self
206            .objects
207            .iter()
208            .filter_map(|(oid, object)| {
209                let source = object
210                    .enrollment_eval_source_internal()
211                    .flatten()
212                    .or_else(|| self.enrollment_eval_sources.get(oid).copied());
213                source
214                    .is_some_and(|source| source.0 == *monitored_oid)
215                    .then_some(*oid)
216            })
217            .collect::<Vec<_>>();
218        self.invalid_enrollment_eval_state.extend(affected);
219    }
220
221    /// Remove an object by identifier.
222    pub fn remove(&mut self, oid: &ObjectIdentifier) -> Option<Box<dyn BACnetObject>> {
223        if self.objects.contains_key(oid) {
224            self.invalidate_enrollments_monitoring(oid);
225        }
226        if let Some(mut obj) = self.objects.remove(oid) {
227            self.enrollment_eval_sources.remove(oid);
228            if obj.enrollment_eval_state_internal().is_some() {
229                if obj
230                    .set_enrollment_eval_state_internal(Default::default())
231                    .is_err()
232                {
233                    self.invalid_enrollment_eval_state.insert(*oid);
234                } else {
235                    self.invalid_enrollment_eval_state.remove(oid);
236                }
237                let _ = obj.set_enrollment_eval_source_internal(None);
238            } else {
239                self.invalid_enrollment_eval_state.remove(oid);
240            }
241            self.name_index.remove(obj.object_name());
242            if let Some(type_set) = self.type_index.get_mut(&oid.object_type()) {
243                type_set.retain(|o| o != oid);
244            }
245            Some(obj)
246        } else {
247            None
248        }
249    }
250
251    /// List all object identifiers in the database.
252    pub fn list_objects(&self) -> Vec<ObjectIdentifier> {
253        self.objects.keys().copied().collect()
254    }
255
256    /// Find all objects of a given type.
257    ///
258    /// Returns a `Vec` of `ObjectIdentifier`s whose object type matches `object_type`.
259    /// Useful for WhoHas, object enumeration, and similar queries.
260    pub fn find_by_type(&self, object_type: ObjectType) -> Vec<ObjectIdentifier> {
261        self.type_index
262            .get(&object_type)
263            .cloned()
264            .unwrap_or_default()
265    }
266
267    /// Iterate over all `(ObjectIdentifier, &dyn BACnetObject)` pairs.
268    ///
269    /// Avoids the double-lookup pattern of `list_objects()` followed by `get()`.
270    pub fn iter_objects(&self) -> impl Iterator<Item = (ObjectIdentifier, &dyn BACnetObject)> {
271        self.objects.iter().map(|(&oid, obj)| (oid, obj.as_ref()))
272    }
273
274    /// Bind one clock reader to the database and every object it contains.
275    ///
276    /// Devices added after this call receive the same reader in [`add`](Self::add).
277    pub fn set_clock_reader(&mut self, clock: Option<Arc<dyn ClockReader>>) {
278        self.clock = clock;
279        for object in self.objects.values_mut() {
280            object.bind_clock_internal(self.clock.clone());
281        }
282    }
283
284    /// Bind one process-local monotonic source to every contained object.
285    #[doc(hidden)]
286    pub fn set_monotonic_clock_internal(&mut self, clock: Option<Arc<MonotonicClock>>) {
287        self.monotonic_clock = clock;
288        for object in self.objects.values_mut() {
289            object.bind_monotonic_clock_internal(self.monotonic_clock.clone());
290        }
291    }
292
293    /// Read one coherent sample from the database's shared clock.
294    pub fn clock_frame(&self) -> Option<ClockFrame> {
295        self.clock.as_ref()?.read_clock()
296    }
297
298    /// Consume the next Device-local EventNotification sequence number.
299    ///
300    /// The counter wraps modulo 65536 as required by the timestamp production.
301    #[doc(hidden)]
302    pub fn next_event_sequence_number(&mut self) -> u16 {
303        let reservation = self.reserve_event_sequence_number();
304        let number = reservation.number();
305        let confirmed = self.confirm_event_sequence_number(reservation);
306        debug_assert!(
307            confirmed,
308            "a reservation cannot become stale without mutation"
309        );
310        number
311    }
312
313    /// Reserve the current sequence number without consuming it.
314    #[doc(hidden)]
315    pub fn reserve_event_sequence_number(&self) -> EventSequenceReservation {
316        EventSequenceReservation {
317            number: self.event_sequence_number,
318        }
319    }
320
321    /// Consume an exact reservation if it is still current.
322    #[doc(hidden)]
323    pub fn confirm_event_sequence_number(&mut self, reservation: EventSequenceReservation) -> bool {
324        if reservation.number != self.event_sequence_number {
325            return false;
326        }
327        self.event_sequence_number = self.event_sequence_number.wrapping_add(1);
328        true
329    }
330
331    /// Visit every `(ObjectIdentifier, &mut dyn BACnetObject)` pair.
332    ///
333    /// Mutable counterpart to [`iter_objects`](Self::iter_objects), used by
334    /// the intrinsic-reporting tick task to advance pending transitions. A
335    /// callback is used (rather than a returned `impl Iterator`) so the
336    /// `&mut dyn BACnetObject` borrow is tied to this call's lifetime.
337    pub fn for_each_object_mut<F>(&mut self, mut f: F)
338    where
339        F: FnMut(ObjectIdentifier, &mut dyn BACnetObject),
340    {
341        for (&oid, obj) in self.objects.iter_mut() {
342            f(oid, obj.as_mut());
343        }
344    }
345
346    /// Number of objects in the database.
347    pub fn len(&self) -> usize {
348        self.objects.len()
349    }
350
351    /// Whether the database is empty.
352    pub fn is_empty(&self) -> bool {
353        self.objects.is_empty()
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use std::borrow::Cow;
360
361    use super::*;
362    use crate::analog::{AnalogInputObject, AnalogOutputObject, AnalogValueObject};
363    use crate::binary::{BinaryInputObject, BinaryOutputObject, BinaryValueObject};
364    use crate::event::{EventStateChange, EventTransition, EventTransitionCommit};
365    use crate::multistate::{MultiStateInputObject, MultiStateOutputObject, MultiStateValueObject};
366    use bacnet_types::enums::{ErrorClass, ErrorCode, EventState, ObjectType, PropertyIdentifier};
367    use bacnet_types::error::Error;
368    use bacnet_types::primitives::{BACnetTimeStamp, PropertyValue};
369
370    #[test]
371    fn all_nine_builtin_intrinsic_families_implement_atomic_commit() {
372        let mut objects: Vec<Box<dyn BACnetObject>> = vec![
373            Box::new(AnalogInputObject::new(1, "AI", 0).unwrap()),
374            Box::new(AnalogOutputObject::new(1, "AO", 0).unwrap()),
375            Box::new(AnalogValueObject::new(1, "AV", 0).unwrap()),
376            Box::new(BinaryInputObject::new(1, "BI").unwrap()),
377            Box::new(BinaryOutputObject::new(1, "BO").unwrap()),
378            Box::new(BinaryValueObject::new(1, "BV").unwrap()),
379            Box::new(MultiStateInputObject::new(1, "MSI", 3).unwrap()),
380            Box::new(MultiStateOutputObject::new(1, "MSO", 3).unwrap()),
381            Box::new(MultiStateValueObject::new(1, "MSV", 3).unwrap()),
382        ];
383
384        for object in &mut objects {
385            assert!(
386                object.intrinsic_reporting_requires_atomic_commit(),
387                "{} must opt into the atomic server path",
388                object.object_name()
389            );
390            object
391                .commit_event_transition_internal(EventTransitionCommit {
392                    change: EventStateChange {
393                        from: EventState::NORMAL,
394                        to: EventState::OFFNORMAL,
395                    },
396                    coordinate: EventTransition::ToOffnormal,
397                    ack_required: true,
398                    timestamp: BACnetTimeStamp::SequenceNumber(73),
399                    message_text: None,
400                })
401                .unwrap_or_else(|error| {
402                    panic!(
403                        "{} must implement the shared commit hook: {error:?}",
404                        object.object_name()
405                    )
406                });
407
408            assert_eq!(
409                object
410                    .read_property(PropertyIdentifier::EVENT_STATE, None)
411                    .unwrap(),
412                PropertyValue::Enumerated(EventState::OFFNORMAL.to_raw()),
413                "{} Event_State",
414                object.object_name()
415            );
416            assert_eq!(
417                object
418                    .read_property(PropertyIdentifier::ACKED_TRANSITIONS, None)
419                    .unwrap(),
420                PropertyValue::BitString {
421                    unused_bits: 5,
422                    data: vec![0x60],
423                },
424                "{} Acked_Transitions",
425                object.object_name()
426            );
427        }
428    }
429
430    #[test]
431    fn event_sequence_wraps_and_is_database_local() {
432        let mut first = ObjectDatabase::new();
433        let mut second = ObjectDatabase::new();
434
435        assert_eq!(first.next_event_sequence_number(), 0);
436        assert_eq!(first.next_event_sequence_number(), 1);
437        assert_eq!(second.next_event_sequence_number(), 0);
438
439        first.event_sequence_number = u16::MAX;
440        assert_eq!(first.next_event_sequence_number(), u16::MAX);
441        assert_eq!(first.next_event_sequence_number(), 0);
442        assert_eq!(second.next_event_sequence_number(), 1);
443    }
444
445    #[test]
446    fn event_sequence_reservation_is_non_consuming_until_confirmed() {
447        let mut db = ObjectDatabase::new();
448
449        let reservation = db.reserve_event_sequence_number();
450        assert_eq!(reservation.number(), 0);
451        assert_eq!(db.reserve_event_sequence_number().number(), 0);
452        assert!(db.confirm_event_sequence_number(reservation));
453        assert_eq!(db.reserve_event_sequence_number().number(), 1);
454    }
455
456    /// Minimal test object.
457    struct TestObject {
458        oid: ObjectIdentifier,
459        name: String,
460    }
461
462    impl BACnetObject for TestObject {
463        fn object_identifier(&self) -> ObjectIdentifier {
464            self.oid
465        }
466
467        fn object_name(&self) -> &str {
468            &self.name
469        }
470
471        fn read_property(
472            &self,
473            property: PropertyIdentifier,
474            _array_index: Option<u32>,
475        ) -> Result<PropertyValue, Error> {
476            if property == PropertyIdentifier::OBJECT_NAME {
477                Ok(PropertyValue::CharacterString(self.name.clone()))
478            } else {
479                Err(Error::Protocol {
480                    class: ErrorClass::PROPERTY.to_raw() as u32,
481                    code: ErrorCode::UNKNOWN_PROPERTY.to_raw() as u32,
482                })
483            }
484        }
485
486        fn write_property(
487            &mut self,
488            _property: PropertyIdentifier,
489            _array_index: Option<u32>,
490            _value: PropertyValue,
491            _priority: Option<u8>,
492        ) -> Result<(), Error> {
493            Err(Error::Protocol {
494                class: ErrorClass::PROPERTY.to_raw() as u32,
495                code: ErrorCode::WRITE_ACCESS_DENIED.to_raw() as u32,
496            })
497        }
498
499        fn property_list(&self) -> Cow<'static, [PropertyIdentifier]> {
500            Cow::Borrowed(&[PropertyIdentifier::OBJECT_NAME])
501        }
502    }
503
504    fn make_test_object(instance: u32) -> Box<dyn BACnetObject> {
505        Box::new(TestObject {
506            oid: ObjectIdentifier::new(ObjectType::ANALOG_INPUT, instance).unwrap(),
507            name: format!("AI-{instance}"),
508        })
509    }
510
511    fn make_test_object_typed(
512        object_type: ObjectType,
513        instance: u32,
514        name: &str,
515    ) -> Box<dyn BACnetObject> {
516        Box::new(TestObject {
517            oid: ObjectIdentifier::new(object_type, instance).unwrap(),
518            name: name.to_string(),
519        })
520    }
521
522    #[test]
523    fn add_and_get() {
524        let mut db = ObjectDatabase::new();
525        let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap();
526        db.add(make_test_object(1)).unwrap();
527        assert_eq!(db.len(), 1);
528
529        let obj = db.get(&oid).unwrap();
530        assert_eq!(obj.object_name(), "AI-1");
531    }
532
533    #[test]
534    fn get_nonexistent_returns_none() {
535        let db = ObjectDatabase::new();
536        let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 99).unwrap();
537        assert!(db.get(&oid).is_none());
538    }
539
540    #[test]
541    fn read_property_via_database() {
542        let mut db = ObjectDatabase::new();
543        db.add(make_test_object(1)).unwrap();
544        let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap();
545        let obj = db.get(&oid).unwrap();
546        let val = obj
547            .read_property(PropertyIdentifier::OBJECT_NAME, None)
548            .unwrap();
549        assert_eq!(val, PropertyValue::CharacterString("AI-1".into()));
550    }
551
552    #[test]
553    fn remove_object() {
554        let mut db = ObjectDatabase::new();
555        let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap();
556        db.add(make_test_object(1)).unwrap();
557        assert_eq!(db.len(), 1);
558        let removed = db.remove(&oid);
559        assert!(removed.is_some());
560        assert_eq!(db.len(), 0);
561    }
562
563    #[test]
564    fn list_objects() {
565        let mut db = ObjectDatabase::new();
566        db.add(make_test_object(1)).unwrap();
567        db.add(make_test_object(2)).unwrap();
568        let oids = db.list_objects();
569        assert_eq!(oids.len(), 2);
570    }
571
572    #[test]
573    fn find_by_type_returns_matching_objects() {
574        let mut db = ObjectDatabase::new();
575        db.add(make_test_object_typed(ObjectType::ANALOG_INPUT, 1, "AI-1"))
576            .unwrap();
577        db.add(make_test_object_typed(ObjectType::ANALOG_INPUT, 2, "AI-2"))
578            .unwrap();
579        db.add(make_test_object_typed(ObjectType::BINARY_INPUT, 1, "BI-1"))
580            .unwrap();
581        db.add(make_test_object_typed(ObjectType::ANALOG_OUTPUT, 1, "AO-1"))
582            .unwrap();
583
584        let ai_oids = db.find_by_type(ObjectType::ANALOG_INPUT);
585        assert_eq!(ai_oids.len(), 2);
586        for oid in &ai_oids {
587            assert_eq!(oid.object_type(), ObjectType::ANALOG_INPUT);
588        }
589
590        let bi_oids = db.find_by_type(ObjectType::BINARY_INPUT);
591        assert_eq!(bi_oids.len(), 1);
592        assert_eq!(bi_oids[0].object_type(), ObjectType::BINARY_INPUT);
593        assert_eq!(bi_oids[0].instance_number(), 1);
594
595        let ao_oids = db.find_by_type(ObjectType::ANALOG_OUTPUT);
596        assert_eq!(ao_oids.len(), 1);
597    }
598
599    #[test]
600    fn find_by_type_returns_empty_for_no_matches() {
601        let mut db = ObjectDatabase::new();
602        db.add(make_test_object_typed(ObjectType::ANALOG_INPUT, 1, "AI-1"))
603            .unwrap();
604
605        let results = db.find_by_type(ObjectType::BINARY_VALUE);
606        assert!(results.is_empty());
607    }
608
609    #[test]
610    fn find_by_type_on_empty_database() {
611        let db = ObjectDatabase::new();
612        let results = db.find_by_type(ObjectType::ANALOG_INPUT);
613        assert!(results.is_empty());
614    }
615
616    #[test]
617    fn iter_objects_yields_all_entries() {
618        let mut db = ObjectDatabase::new();
619        db.add(make_test_object_typed(ObjectType::ANALOG_INPUT, 1, "AI-1"))
620            .unwrap();
621        db.add(make_test_object_typed(ObjectType::BINARY_INPUT, 1, "BI-1"))
622            .unwrap();
623
624        let items: Vec<_> = db.iter_objects().collect();
625        assert_eq!(items.len(), 2);
626
627        // Verify we can access object data without a second lookup
628        for (oid, obj) in &items {
629            assert_eq!(oid.object_type(), obj.object_identifier().object_type());
630            assert!(!obj.object_name().is_empty());
631        }
632    }
633
634    #[test]
635    fn iter_objects_on_empty_database() {
636        let db = ObjectDatabase::new();
637        assert_eq!(db.iter_objects().count(), 0);
638    }
639
640    #[test]
641    fn duplicate_name_rejected() {
642        let mut db = ObjectDatabase::new();
643        db.add(make_test_object_typed(
644            ObjectType::ANALOG_INPUT,
645            1,
646            "Sensor",
647        ))
648        .unwrap();
649        // Different OID, same name → must fail
650        let result = db.add(make_test_object_typed(
651            ObjectType::ANALOG_INPUT,
652            2,
653            "Sensor",
654        ));
655        assert!(result.is_err());
656        assert_eq!(db.len(), 1); // original still there
657    }
658
659    #[test]
660    fn replace_same_oid_allowed() {
661        let mut db = ObjectDatabase::new();
662        db.add(make_test_object_typed(
663            ObjectType::ANALOG_INPUT,
664            1,
665            "Sensor",
666        ))
667        .unwrap();
668        // Same OID, same or different name → allowed (replacement)
669        db.add(make_test_object_typed(
670            ObjectType::ANALOG_INPUT,
671            1,
672            "Sensor-v2",
673        ))
674        .unwrap();
675        assert_eq!(db.len(), 1);
676        let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap();
677        assert_eq!(db.get(&oid).unwrap().object_name(), "Sensor-v2");
678    }
679
680    #[test]
681    fn find_by_name_works() {
682        let mut db = ObjectDatabase::new();
683        db.add(make_test_object_typed(ObjectType::ANALOG_INPUT, 1, "Temp"))
684            .unwrap();
685        db.add(make_test_object_typed(ObjectType::BINARY_INPUT, 1, "Alarm"))
686            .unwrap();
687
688        let obj = db.find_by_name("Temp").unwrap();
689        assert_eq!(obj.object_identifier().instance_number(), 1);
690        assert_eq!(
691            obj.object_identifier().object_type(),
692            ObjectType::ANALOG_INPUT
693        );
694
695        assert!(db.find_by_name("NonExistent").is_none());
696    }
697
698    #[test]
699    fn remove_frees_name() {
700        let mut db = ObjectDatabase::new();
701        let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap();
702        db.add(make_test_object_typed(
703            ObjectType::ANALOG_INPUT,
704            1,
705            "Sensor",
706        ))
707        .unwrap();
708        db.remove(&oid);
709        // Name should now be available for a different object
710        db.add(make_test_object_typed(
711            ObjectType::ANALOG_INPUT,
712            2,
713            "Sensor",
714        ))
715        .unwrap();
716        assert_eq!(db.len(), 1);
717    }
718}