bacnet-endpoint 0.12.0

BACnet endpoint that owns one transport and runs client and server roles through it
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
//! Single Device identity source (RB-16, proven narrow scope).
//!
//! # Truth direction
//!
//! [`DeviceIdentity`] is the single source for one device:
//!
//! ```text
//! DeviceIdentity
//!   -> ObjectDatabase (Device + Network-Port objects + DEVICE_UUID)
//!   -> ServerConfig / ClientConfig / transport limits
//!   -> I-Am contents + Device ReadProperty + role behavior
//! ```
//!
//! I-Am (`object_identifier`, `max_apdu`, `segmentation`, `vendor`) +
//! Device `ReadProperty` + client/server role limits ALL derive from the
//! same identity. The session I-Am path ([`EndpointSession::broadcast_i_am`](crate::session::EndpointSession::broadcast_i_am))
//! encodes from the composed identity; the database is built by
//! [`DeviceIdentity::build_database`] so readback agrees; role limits use
//! [`DeviceIdentity::client_max_apdu`] / [`DeviceIdentity::server_max_apdu`].
//!
//! `SessionConfig::max_apdu_length` (480) stays the default for standalone
//! sessions without an identity; [`EndpointSession::with_identity`](crate::session::EndpointSession::with_identity)
//! overrides it when an identity is composed.
//!
//! # Evidence level (honest)
//!
//! I-Am identical to Device `ReadProperty` for the composed identity, with
//! role traffic both directions, is corroborated on **real B/IP loopback UDP
//! and the real constrained-TLS SC hub** (RB-16 proofs). Port/UUID/capability
//! corners beyond that matrix — Network-Port population details, SC UUID
//! sync into `DEVICE_UUID`, service-profile alignment — are **Loopback-only**
//! (deterministic `LoopbackTransport` / `LoopbackWebSocket` coverage), not
//! on-wire claims.
//!
//! # Services profile (no superset flags)
//!
//! Default = the endpoint server-executed set (`READ_PROPERTY` only). That is
//! the narrow composition reality in this crate: the client role initiates
//! `ReadProperty`, the server role executes `ReadProperty` (plus `Reject`/`Abort`
//! and segmentation-`Abort`). It deliberately differs from the full
//! `bacnet-server` dispatch surface (`EXECUTED_SERVICES`): advertising the
//! full set here would be a superset flag the endpoint roles cannot honor.
//! Explicit [`EndpointSession::with_device_writes`](crate::session::EndpointSession::with_device_writes)
//! adds `WRITE_PROPERTY` for authorized local Device.Description writes and
//! sets the Device and identity to that actual two-service profile at startup.
//! This opt-in rejects any other configured identity service bits before start.
//! Deployments needing the full server surface override via
//! [`DeviceIdentity::with_services`], but must then compose the full server —
//! not the narrow endpoint responder — or the I-Am vs ReadProperty vs behavior
//! matrix fails.
//!
//! # Network-Port population rule
//!
//! Entries declare configured, unbound application-port snapshots. They do not
//! discover or prove an active socket/NIC association:
//!
//! - B/IP: IPV4 (5), six-octet MAC derived from configured IP/UDP, and caller
//!   network number. UDP zero is allowed; the local instance/Port ID is 1..255.
//! - SC/loopback: VIRTUAL (7), caller VMAC and number; the object exposes only
//!   common application rows, with no IPv4 properties or complete SC claim.
//! - Port APDU_Length399 is declared1476 independently of Device62/role limits.
//!
//! Instance numbering is stable per device (proofs use B/IP1, SC2). Explicit
//! endpoint registration reconciles the selected entry and database together
//! before publishing the owned B/IP transport; declarations alone remain unbound.
//!
//! # SC UUID rule (durable-caller-owned)
//!
//! Mirrors `sc_builder` docs: the builder neither generates nor stores the
//! device UUID. The caller provisions 16 bytes durably (Annex AB.1.5.3) and
//! reuses them for the device lifetime; [`DeviceIdentity::with_device_uuid`]
//! only stores the supplied bytes, and [`DeviceIdentity::build_database`]
//! syncs them into the Device-object `DEVICE_UUID` instead of leaving zeros.
//! Startup rejects all-zero only where a real SC identity is required
//! (SC transport start, SC endpoint hub-dial); B/IP-only identities may keep
//! zeros and still build.
//!
//! # Transport-dependent behavior
//!
//! - B/IP real loopback UDP (`127.0.0.1`, ephemeral port): one socket per
//!   endpoint; broadcast reaches the bound socket via `INADDR_ANY`; I-Am via
//!   local broadcast; routed NPDU via unicast to the router MAC.
//! - SC constrained-TLS hub (`ScHub` + `TlsWebSocket`): hub relay preserves
//!   `source_network` + `provenance` (verified-relayed-origin); VMAC+UUID
//!   survive reconnect via caller-owned reconnect config; direct dial is out
//!   of scope for the proof.
//! - In-memory `LoopbackTransport` / `LoopbackWebSocket` appear alongside for
//!   determinism, never INSTEAD of the real-loopback proofs.

use bacnet_encoding::apdu::{encode_apdu, validate_max_apdu_length, Apdu, UnconfirmedRequest};
use bacnet_objects::database::ObjectDatabase;
use bacnet_objects::device::{DeviceConfig, DeviceObject};
use bacnet_objects::network_port::{BipPortConfig, NetworkPortObject};
use bacnet_objects::traits::BACnetObject;
use bacnet_services::who_is::IAmRequest;
use bacnet_types::enums::{NetworkType, ObjectType, Segmentation, ServiceSupported};
use bacnet_types::error::Error;
use bacnet_types::primitives::ObjectIdentifier;
use bacnet_types::MacAddr;
use bytes::BytesMut;
use std::net::Ipv4Addr;

/// One configured Network-Port entry (see module docs for the profile boundary).
///
/// Provenance: population-rule coverage is Loopback-only except the B/IP +
/// SC entries exercised in the RB-16 real-transport proofs. Field layout
/// follows Clause 12.56; see the module docs for per-transport semantics.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NetworkPortEntry {
    /// Network-Port object instance (caller-stable; B/IP=1, SC=2 by convention).
    pub instance: u32,
    /// Clause 12.56 Network_Type (IPV4=5 for B/IP, VIRTUAL=7 for SC/loopback).
    pub network_type: u32,
    /// BACnet network number for this port.
    pub network_number: u32,
    /// Port MAC (B/IP 6-byte IP+port, SC VMAC, loopback MAC).
    /// B/IP admission rejects a MAC inconsistent with the configured IP/UDP.
    pub mac: MacAddr,
    /// IPv4 octets for B/IP; zeros for SC/loopback.
    pub ip: [u8; 4],
    /// UDP port for B/IP; 0 for SC/loopback.
    pub udp_port: u16,
}

impl NetworkPortEntry {
    /// B/IP entry with the configured IP/UDP port (possibly unbound).
    ///
    /// Derives the 6-byte B/IP MAC (IP octets + big-endian UDP port) from the
    /// configured socket address. Explicit endpoint registration reconciles the
    /// selected entry with its actual ephemeral port during startup.
    ///
    /// ```
    /// use std::net::Ipv4Addr;
    /// use bacnet_endpoint::identity::NetworkPortEntry;
    ///
    /// let entry = NetworkPortEntry::bip(1, 0, Ipv4Addr::LOCALHOST, 47808);
    /// assert_eq!(entry.instance, 1);
    /// assert_eq!(entry.udp_port, 47808);
    /// ```
    pub fn bip(instance: u32, network_number: u32, ip: Ipv4Addr, udp_port: u16) -> Self {
        let o = ip.octets();
        let mut mac = MacAddr::new();
        mac.extend_from_slice(&o);
        mac.extend_from_slice(&udp_port.to_be_bytes());
        Self {
            instance,
            network_type: NetworkType::IPV4.to_raw(),
            network_number,
            mac,
            ip: o,
            udp_port,
        }
    }

    /// Build the declared configuration; this does not inspect an active port.
    fn build_object(&self) -> Result<NetworkPortObject, Error> {
        let network_number = u16::try_from(self.network_number)
            .map_err(|_| Error::Encoding("network number must be 0..65534".into()))?;
        let name = format!("port-{}", self.instance);
        // Configured B/IP/SC capacity, independently of Device62/role limits.
        // Live transport association and capability validation are separate.
        let apdu_length = 1476;
        if self.network_type == NetworkType::IPV4.to_raw() {
            // Public entry fields must describe one configured address. Reject
            // divergent input instead of storing one MAC while deriving another
            // in either database builder.
            let [a, b, c, d] = self.ip;
            let [hi, lo] = self.udp_port.to_be_bytes();
            if self.mac.as_slice() != [a, b, c, d, hi, lo] {
                return Err(Error::Encoding(
                    "B/IP MAC must match configured IPv4 address and UDP port".into(),
                ));
            }
            NetworkPortObject::new_bip(
                self.instance,
                name,
                BipPortConfig {
                    network_number,
                    apdu_length,
                    ip_address: self.ip,
                    udp_port: self.udp_port,
                    ..Default::default()
                },
            )
        } else {
            NetworkPortObject::new_non_bip(
                self.instance,
                name,
                NetworkType::from_raw(self.network_type),
                network_number,
                self.mac.clone(),
                apdu_length,
            )
        }
    }

    /// SC entry keyed by VMAC (no B/IP socket fields).
    ///
    /// IP octets stay zero and the UDP port stays 0: SC has no B/IP socket.
    /// The VMAC must equal the SC builder's VMAC at hub-dial build time.
    pub fn sc(instance: u32, network_number: u32, vmac: [u8; 6]) -> Self {
        Self {
            instance,
            network_type: NetworkType::VIRTUAL.to_raw(),
            network_number,
            mac: MacAddr::from_slice(&vmac),
            ip: [0; 4],
            udp_port: 0,
        }
    }
}

/// Single Device identity source.
///
/// Owns instance, vendor, max-APDU, segmentation, services profile,
/// Network-Port set, and SC UUID. Constructs the database AND derives
/// role/transport limits. I-Am identical to Device ReadProperty is proven on
/// real B/IP + SC; finer port/UUID/capability corners are Loopback-only (see
/// module docs).
///
/// ```
/// use bacnet_endpoint::identity::DeviceIdentity;
///
/// let identity = DeviceIdentity::new(1001, 42)?;
/// assert_eq!(identity.instance(), 1001);
/// assert_eq!(identity.vendor_id(), 42);
/// # Ok::<(), bacnet_types::error::Error>(())
/// ```
#[derive(Clone, Debug)]
pub struct DeviceIdentity {
    instance: u32,
    vendor_id: u16,
    max_apdu_length: u16,
    segmentation_supported: Segmentation,
    services: Vec<ServiceSupported>,
    network_ports: Vec<NetworkPortEntry>,
    device_uuid: [u8; 16],
    name: String,
}

impl DeviceIdentity {
    /// Creates an identity with endpoint-composed defaults.
    ///
    /// Defaults: `max_apdu = 1476`, `segmentation = NONE`,
    /// `services = [READ_PROPERTY]` (narrow endpoint reality, no superset),
    /// no ports, zero UUID (B/IP-only may keep zeros; SC dial requires
    /// [`with_device_uuid`](Self::with_device_uuid)). Returns
    /// [`Error::Encoding`] for an
    /// out-of-range Device instance.
    pub fn new(instance: u32, vendor_id: u16) -> Result<Self, Error> {
        ObjectIdentifier::new(ObjectType::DEVICE, instance)?;
        Ok(Self {
            instance,
            vendor_id,
            max_apdu_length: 1476,
            segmentation_supported: Segmentation::NONE,
            services: vec![ServiceSupported::READ_PROPERTY],
            network_ports: Vec::new(),
            device_uuid: [0; 16],
            name: format!("device-{instance}"),
        })
    }

    /// Overrides the max-APDU accepted/advertised (must be a wire-legal value).
    ///
    /// Validated by the codec's APDU-length table; illegal values return
    /// [`Error::Encoding`]. Note the
    /// MS/TP builder additionally rejects values above its 480 transport
    /// bound at build time.
    pub fn with_max_apdu(mut self, max_apdu: u16) -> Result<Self, Error> {
        validate_max_apdu_length(max_apdu)?;
        self.max_apdu_length = max_apdu;
        Ok(self)
    }

    /// Overrides segmentation support (endpoint proofs use NONE).
    ///
    /// The endpoint roles answer segmentation-`Abort`; advertising support
    /// beyond what the composed roles honor breaks the I-Am vs behavior
    /// matrix.
    pub fn with_segmentation(mut self, segmentation: Segmentation) -> Self {
        self.segmentation_supported = segmentation;
        self
    }

    /// Overrides the services profile (must equal what roles can do; no superset).
    ///
    /// Default `[READ_PROPERTY]` matches the narrow endpoint responder.
    /// `EndpointSession::with_device_writes` adds `WRITE_PROPERTY` and rejects
    /// other service bits. Server-role startup also rejects missing ReadProperty
    /// or unsupported bits when writes are disabled, before ingress starts.
    /// ClientOnly has no responder and retains this as a local declaration.
    /// Broader executing profiles require a matching full server.
    pub fn with_services(mut self, services: &[ServiceSupported]) -> Self {
        self.services = services.to_vec();
        self
    }

    /// Stores the durable caller-owned SC device UUID (no generation/storage).
    ///
    /// The caller provisions 16 bytes durably and reuses them for the device
    /// lifetime. SC hub-dial rejects all-zero; B/IP-only identities may keep
    /// zeros.
    pub fn with_device_uuid(mut self, uuid: [u8; 16]) -> Self {
        self.device_uuid = uuid;
        self
    }

    /// Overrides the Device object name (defaults to `device-{instance}`).
    pub fn with_name(mut self, name: impl Into<String>) -> Self {
        self.name = name.into();
        self
    }

    /// Adds one validated configured Network-Port entry.
    ///
    /// Rejects duplicate instances and out-of-range Network-Port instances
    /// and invalid profile configuration.
    /// Prefer [`with_bip_port`](Self::with_bip_port) /
    /// [`with_sc_port`](Self::with_sc_port), which encode the population rule.
    pub fn with_network_port(mut self, entry: NetworkPortEntry) -> Result<Self, Error> {
        entry.build_object()?;
        if self
            .network_ports
            .iter()
            .any(|e| e.instance == entry.instance)
        {
            return Err(Error::Encoding(format!(
                "duplicate Network-Port instance {}",
                entry.instance
            )));
        }
        self.network_ports.push(entry);
        Ok(self)
    }

    /// Adds a B/IP port entry with configured IP/UDP values, including UDP zero.
    pub fn with_bip_port(
        self,
        instance: u32,
        network_number: u32,
        ip: Ipv4Addr,
        udp_port: u16,
    ) -> Result<Self, Error> {
        self.with_network_port(NetworkPortEntry::bip(
            instance,
            network_number,
            ip,
            udp_port,
        ))
    }

    /// Adds an SC port entry keyed by VMAC.
    pub fn with_sc_port(
        self,
        instance: u32,
        network_number: u32,
        vmac: [u8; 6],
    ) -> Result<Self, Error> {
        self.with_network_port(NetworkPortEntry::sc(instance, network_number, vmac))
    }

    pub(crate) fn validate_registered_bip(
        &self,
        oid: ObjectIdentifier,
        config: &BipPortConfig,
    ) -> Result<(), Error> {
        let entry = self
            .network_ports
            .iter()
            .find(|entry| entry.instance == oid.instance_number())
            .ok_or_else(|| Error::Encoding("selected Network Port missing from identity".into()))?;
        entry.build_object()?;
        if entry.network_type != NetworkType::IPV4.to_raw()
            || entry.ip != config.ip_address
            || entry.udp_port != config.udp_port
            || entry.network_number != u32::from(config.network_number)
        {
            return Err(Error::Encoding(
                "selected identity Network Port differs from database".into(),
            ));
        }
        Ok(())
    }

    pub(crate) fn publish_registered_bip(
        &mut self,
        oid: ObjectIdentifier,
        address: std::net::SocketAddrV4,
    ) {
        let entry = self
            .network_ports
            .iter_mut()
            .find(|entry| entry.instance == oid.instance_number())
            .expect("validated identity registration");
        *entry = NetworkPortEntry::bip(
            entry.instance,
            entry.network_number,
            *address.ip(),
            address.port(),
        );
    }

    /// Device instance number.
    pub fn instance(&self) -> u32 {
        self.instance
    }

    /// Vendor identifier (I-Am + Device VENDOR_IDENTIFIER).
    pub fn vendor_id(&self) -> u16 {
        self.vendor_id
    }

    /// Composed max-APDU (I-Am + Device + client/server limits).
    pub fn max_apdu_length(&self) -> u16 {
        self.max_apdu_length
    }

    /// Client role max-APDU (same composed value; overrides SessionConfig 480).
    pub fn client_max_apdu(&self) -> u16 {
        self.max_apdu_length
    }

    /// Server role max-APDU clamp (same composed value).
    pub fn server_max_apdu(&self) -> u16 {
        self.max_apdu_length
    }

    /// Segmentation support (I-Am + Device SEGMENTATION_SUPPORTED).
    pub fn segmentation(&self) -> Segmentation {
        self.segmentation_supported
    }

    /// Services profile (Device PROTOCOL_SERVICES_SUPPORTED; no superset).
    pub fn services(&self) -> &[ServiceSupported] {
        &self.services
    }

    /// Configured Network-Port entries, independent of live transport binding.
    pub fn network_ports(&self) -> &[NetworkPortEntry] {
        &self.network_ports
    }

    /// Durable caller-owned SC device UUID.
    pub fn device_uuid(&self) -> [u8; 16] {
        self.device_uuid
    }

    /// Device object identifier derived from the instance.
    pub fn device_oid(&self) -> ObjectIdentifier {
        ObjectIdentifier::new(ObjectType::DEVICE, self.instance)
            .expect("identity instance was validated at construction")
    }

    /// I-Am request derived from this identity (endpoint + server alignment).
    ///
    /// Field-for-field identical to `broadcast_i_am_from`'s construction from
    /// a [`ServerConfig`](bacnet_server::server::ServerConfig) derived via
    /// [`Self::apply_to_server_config`]: object id + max-apdu + segmentation
    /// + vendor. Assert on wire bytes where practical (see proof tests).
    pub fn iam_request(&self) -> IAmRequest {
        IAmRequest {
            object_identifier: self.device_oid(),
            max_apdu_length: u32::from(self.max_apdu_length),
            segmentation_supported: self.segmentation_supported,
            vendor_id: self.vendor_id,
        }
    }

    /// Encodes the I-Am Unconfirmed-Request APDU bytes for this identity.
    pub fn encode_iam_apdu(&self) -> Result<Vec<u8>, Error> {
        let mut service = BytesMut::new();
        self.iam_request().encode(&mut service);
        let mut buf = BytesMut::new();
        encode_apdu(
            &mut buf,
            &Apdu::UnconfirmedRequest(UnconfirmedRequest {
                service_choice: bacnet_types::enums::UnconfirmedServiceChoice::I_AM,
                service_request: service.freeze(),
            }),
        )?;
        Ok(buf.to_vec())
    }

    /// Constructs the ObjectDatabase (Device + Network-Port + DEVICE_UUID).
    ///
    /// Truth direction: identity -> database. For extra application objects
    /// use [`build_database_with_extra`]: it seeds `Object_List` with
    /// Device + ports + extras upfront so no post-add mutation is needed.
    pub fn build_database(&self) -> Result<ObjectDatabase, Error> {
        let mut device = DeviceObject::new(DeviceConfig {
            instance: self.instance,
            name: self.name.clone(),
            vendor_name: "Rusty BACnet".into(),
            vendor_id: self.vendor_id,
            model_name: "rusty-bacnet-endpoint".into(),
            firmware_revision: env!("CARGO_PKG_VERSION").into(),
            application_software_version: env!("CARGO_PKG_VERSION").into(),
            max_apdu_length: u32::from(self.max_apdu_length),
            segmentation_supported: self.segmentation_supported,
            apdu_timeout: 6000,
            apdu_retries: 3,
        })?;
        device.set_services_supported(&self.services);
        device.set_device_uuid(self.device_uuid);
        let device_oid = device.object_identifier();

        let mut oids = vec![device_oid];
        let mut ports = Vec::new();
        for entry in &self.network_ports {
            let port = entry.build_object()?;
            oids.push(port.object_identifier());
            ports.push(port);
        }
        device.set_object_list(oids);

        let mut db = ObjectDatabase::new();
        db.add(Box::new(device))?;
        for port in ports {
            db.add(Box::new(port))?;
        }
        Ok(db)
    }

    /// Applies identity limits to a standalone SessionConfig (overrides 480).
    pub fn apply_to_session_config(&self, config: &mut crate::session::SessionConfig) {
        config.max_apdu_length = self.max_apdu_length;
    }

    /// Applies identity to a client config (max-APDU only; timers stay caller-owned).
    pub fn apply_to_client_config(&self, config: &mut bacnet_client::client::ClientConfig) {
        config.max_apdu_length = self.max_apdu_length;
    }

    /// Applies identity to a server config (max-apdu + segmentation + vendor).
    ///
    /// This is the discovery-alignment bridge: a `ServerConfig` derived here
    /// makes `broadcast_i_am_from` emit bytes identical to
    /// [`Self::encode_iam_apdu`].
    pub fn apply_to_server_config(&self, config: &mut bacnet_server::server::ServerConfig) {
        config.max_apdu_length = u32::from(self.max_apdu_length);
        config.segmentation_supported = self.segmentation_supported;
        config.vendor_id = self.vendor_id;
    }

    /// Derives a server config from this identity (discovery-alignment helper).
    pub fn server_config(&self) -> bacnet_server::server::ServerConfig {
        let mut config = bacnet_server::server::ServerConfig::default();
        self.apply_to_server_config(&mut config);
        config
    }

    /// Derives a client config from this identity (timers stay default).
    pub fn client_config(&self) -> bacnet_client::client::ClientConfig {
        let mut config = bacnet_client::client::ClientConfig::default();
        self.apply_to_client_config(&mut config);
        config
    }
}

/// Builds a database from an identity plus extra application objects.
///
/// Truth direction stays identity-first: the Device `Object_List` is seeded
/// with Device + ports + every extra OID upfront, so no post-add mutation
/// (and no device/network-port fork) is needed. Extra objects are boxed
/// `BACnetObject`s (e.g. Analog Input for proofs).
///
/// ```
/// use bacnet_endpoint::identity::{build_database_with_extra, DeviceIdentity};
/// use bacnet_objects::analog::AnalogInputObject;
///
/// let identity = DeviceIdentity::new(1001, 42)?;
/// let mut point = AnalogInputObject::new(1, "ai-1", 0)?;
/// point.set_present_value(21.5);
/// let db = build_database_with_extra(&identity, vec![Box::new(point)])?;
/// assert!(db.get(&identity.device_oid()).is_some());
/// # Ok::<(), bacnet_types::error::Error>(())
/// ```
pub fn build_database_with_extra(
    identity: &DeviceIdentity,
    extra: Vec<Box<dyn bacnet_objects::traits::BACnetObject>>,
) -> Result<ObjectDatabase, Error> {
    let mut device = DeviceObject::new(DeviceConfig {
        instance: identity.instance,
        name: identity.name.clone(),
        vendor_name: "Rusty BACnet".into(),
        vendor_id: identity.vendor_id,
        model_name: "rusty-bacnet-endpoint".into(),
        firmware_revision: env!("CARGO_PKG_VERSION").into(),
        application_software_version: env!("CARGO_PKG_VERSION").into(),
        max_apdu_length: u32::from(identity.max_apdu_length),
        segmentation_supported: identity.segmentation_supported,
        apdu_timeout: 6000,
        apdu_retries: 3,
    })?;
    device.set_services_supported(&identity.services);
    device.set_device_uuid(identity.device_uuid);
    let device_oid = device.object_identifier();

    let mut oids = vec![device_oid];
    let mut ports = Vec::new();
    for entry in &identity.network_ports {
        let port = entry.build_object()?;
        oids.push(port.object_identifier());
        ports.push(port);
    }
    for obj in &extra {
        oids.push(obj.object_identifier());
    }
    device.set_object_list(oids);

    let mut db = ObjectDatabase::new();
    db.add(Box::new(device))?;
    for port in ports {
        db.add(Box::new(port))?;
    }
    for obj in extra {
        db.add(obj)?;
    }
    Ok(db)
}