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
//! Concrete B/IP endpoint builder (no generic-trait erasure).
//!
//! Closes the server-BIP-builder gap: `BipServerBuilder` lacks BDT/FDT
//! controls while [`BipTransport`] + [`bbmd`](bacnet_transport::bbmd) own
//! them. This builder exposes them first-class on the endpoint composition
//! above the sibling roles (concrete `BipTransport`, never `impl
//! TransportPort`).
//!
//! # Evidence level
//!
//! Plain B/IP (unicast + local broadcast over one real UDP socket) is proven
//! on real loopback UDP (RB-16): one socket per session, bidirectional
//! confirmed traffic, I-Am identical to Device ReadProperty. BBMD and foreign
//! modes have bounded local Network Number wire coverage: ServerOnly BBMD
//! admission and Original-Broadcast replies, ClientOnly foreign DBTN/retry and
//! alternate forwarders, and Both requester/responder progress with cleanup.
//! Broader BBMD/foreign behavior remains experimental. Management ACL,
//! persistence and fanout controls retain construction-only endpoint coverage.
//! Live BVLC queries (`read_bdt` / `write_bdt` / `read_fdt` / …) stay on
//! [`BipTransport`].
//!
//! ```no_run
//! use std::net::Ipv4Addr;
//!
//! use bacnet_endpoint::bip::BipEndpointBuilder;
//! use bacnet_endpoint::session::SessionRole;
//!
//! # #[tokio::main]
//! # async fn main() -> Result<(), bacnet_types::error::Error> {
//! let mut session = BipEndpointBuilder::new(Ipv4Addr::LOCALHOST, 0, Ipv4Addr::BROADCAST)
//! .role(SessionRole::Both)
//! .queue_capacity(16)
//! .client_timers(2_000, 0)
//! .build_session()?;
//! session.start().await?;
//! session.stop().await?;
//! # Ok(())
//! # }
//! ```
use std::net::{Ipv4Addr, SocketAddrV4};
use bacnet_objects::database::ObjectDatabase;
use bacnet_transport::bbmd::BdtEntry;
use bacnet_transport::bbmd::ForeignDevicePolicy;
use bacnet_transport::bip::{BipTransport, FanoutPolicy, ForeignDeviceConfig};
use bacnet_types::error::Error;
use bacnet_types::primitives::ObjectIdentifier;
use crate::session::{EndpointSession, SessionConfig, SessionRole};
/// Concrete B/IP endpoint builder.
///
/// Pre-start BBMD/FDT controls mirror `transport/bip` + `bbmd.rs`; live
/// BVLC queries (`read_bdt`/`write_bdt`/`read_fdt`/…) stay on
/// [`BipTransport`] for real-transport proofs (RB-16/17).
///
/// Plain mode is proven on real loopback UDP; BBMD/foreign-device setters are
/// experimental beyond the bounded Number wire cases described above.
pub struct BipEndpointBuilder {
interface: Ipv4Addr,
port: u16,
broadcast_address: Ipv4Addr,
role: SessionRole,
session: SessionConfig,
database: Option<ObjectDatabase>,
registered_network_port: Option<ObjectIdentifier>,
identity: Option<crate::identity::DeviceIdentity>,
device_write_authorizer: Option<bacnet_server::mutation::MutationAuthorizer>,
source_audit_bindings: Vec<(ObjectIdentifier, SocketAddrV4)>,
bbmd_bdt: Option<Vec<BdtEntry>>,
foreign_policy: Option<ForeignDevicePolicy>,
management_acl: Option<Vec<[u8; 4]>>,
bdt_persist_path: Option<std::path::PathBuf>,
fanout_policy: Option<FanoutPolicy>,
foreign_device: Option<ForeignDeviceConfig>,
}
impl BipEndpointBuilder {
/// Creates a B/IP endpoint builder with interface/port/broadcast.
///
/// `port = 0` selects an ephemeral port (tests); production uses 47808.
/// `interface` is the announced MAC IP; the socket binds `INADDR_ANY` so
/// subnet/limited broadcast reaches it.
pub fn new(interface: Ipv4Addr, port: u16, broadcast_address: Ipv4Addr) -> Self {
Self {
interface,
port,
broadcast_address,
role: SessionRole::Both,
session: SessionConfig::default(),
database: None,
registered_network_port: None,
identity: None,
device_write_authorizer: None,
source_audit_bindings: Vec::new(),
bbmd_bdt: None,
foreign_policy: None,
management_acl: None,
bdt_persist_path: None,
fanout_policy: None,
foreign_device: None,
}
}
/// Selects the composed roles (default [`Both`](SessionRole::Both)).
pub fn role(mut self, role: SessionRole) -> Self {
self.role = role;
self
}
/// Sets the bounded queue capacity for every ingress/egress queue.
///
/// Must be greater than zero; [`build_session`](Self::build_session)
/// fails otherwise via [`EndpointSession::new`].
pub fn queue_capacity(mut self, capacity: usize) -> Self {
self.session.queue_capacity = capacity;
self
}
/// Sets the server role's read work limit (default 256): the result rows
/// one ReadProperty may expand, a Group's member rows included. See
/// [`SessionConfig::read_work_limit`]; zero fails
/// [`build_session`](Self::build_session) via [`EndpointSession::new`].
pub fn read_work_limit(mut self, limit: usize) -> Self {
self.session.read_work_limit = limit;
self
}
/// Sets client APDU timeout/retries (session-owned timers).
pub fn client_timers(mut self, timeout_ms: u64, retries: u8) -> Self {
self.session.apdu_timeout_ms = timeout_ms;
self.session.apdu_retries = retries;
self
}
/// Attaches the object database for the server responder.
///
/// Build it from the same identity passed to
/// [`identity`](Self::identity) so Device readback agrees with I-Am.
pub fn database(mut self, db: ObjectDatabase) -> Self {
self.database = Some(db);
self
}
/// Explicitly associate the selected concrete built-in port with this NORMAL
/// B/IP transport. Declaring an identity entry alone does not register it.
pub fn registered_network_port(mut self, oid: ObjectIdentifier) -> Self {
self.registered_network_port = Some(oid);
self
}
/// Composes the single Device identity (overrides SessionConfig 480).
///
/// Truth direction: the database should already be built from the same
/// identity (`DeviceIdentity::build_database`); this only wires I-Am +
/// role limits. No generation, no extra socket.
pub fn identity(mut self, identity: crate::identity::DeviceIdentity) -> Self {
self.identity = Some(identity);
self
}
/// Enables authorized writes to the local Device's Description and installed Audit recipient.
///
/// The mandatory callback and atomic startup requirements are those of
/// [`EndpointSession::with_device_writes`]. Other objects/properties and
/// WritePropertyMultiple remain unsupported. Requires `build_session()`;
/// a bare transport cannot retain this session-owned authorization.
pub fn device_writes(
mut self,
authorizer: bacnet_server::mutation::MutationAuthorizer,
) -> Self {
self.device_write_authorizer = Some(authorizer);
self
}
/// Register one trusted direct IPv4 B/IP route for source Audit delivery.
/// This is route data only; provision the recipient on the actual Device.
/// Multiple entries may resolve old/new Device choices. Duplicate keys,
/// non-Device/wildcard identifiers and invalid unicast endpoints fail
/// `build_session()`. A configured broadcast endpoint is ineligible; the
/// actual bound port is rechecked after startup. Address choices need no
/// binding. Without source selection these facts are inert. `build_transport`
/// rejects them because a bare transport cannot retain this route authority.
pub fn source_audit_device_binding(
mut self,
device: ObjectIdentifier,
address: SocketAddrV4,
) -> Self {
self.source_audit_bindings.push((device, address));
self
}
/// Enables BBMD mode with the initial BDT (before start).
///
/// With a `0.0.0.0` interface, the BBMD's own B/IP address comes from the
/// BDT row at one of the host's addresses and the bound port, as described
/// at [`BipTransport::enable_bbmd`]; bind an explicit interface address to
/// avoid that choice.
///
/// Local Number controls have BBMD wire coverage; broader BBMD behavior
/// remains experimental. BBMD controls require this first:
/// [`foreign_device_policy`](Self::foreign_device_policy) /
/// [`bbmd_management_acl`](Self::bbmd_management_acl) without it fail
/// [`build_transport`](Self::build_transport) with a typed
/// [`Error::Encoding`].
pub fn enable_bbmd(mut self, bdt: Vec<BdtEntry>) -> Self {
self.bbmd_bdt = Some(bdt);
self
}
/// Enables foreign-device registration policy (after `enable_bbmd`).
///
/// **Experimental / unproven**: see [`enable_bbmd`](Self::enable_bbmd).
pub fn foreign_device_policy(mut self, policy: ForeignDevicePolicy) -> Self {
self.foreign_policy = Some(policy);
self
}
/// Sets the BBMD Delete-FDT-Entry management ACL (fail-closed when empty).
///
/// **Experimental / unproven**: see [`enable_bbmd`](Self::enable_bbmd).
pub fn bbmd_management_acl(mut self, acl: Vec<[u8; 4]>) -> Self {
self.management_acl = Some(acl);
self
}
/// Sets the externally provisioned persisted-BDT path (wire format).
///
/// **Experimental / unproven**: see [`enable_bbmd`](Self::enable_bbmd).
pub fn bdt_persist_path(mut self, path: std::path::PathBuf) -> Self {
self.bdt_persist_path = Some(path);
self
}
/// Sets the broadcast fanout policy.
///
/// **Experimental / unproven**: staged pre-start only, no wire proof.
pub fn fanout_policy(mut self, policy: FanoutPolicy) -> Self {
self.fanout_policy = Some(policy);
self
}
/// Registers this endpoint as a foreign device (before start).
///
/// Local Number controls have foreign DBTN/retry wire coverage; broader
/// foreign-device behavior remains experimental.
pub fn register_as_foreign_device(mut self, config: ForeignDeviceConfig) -> Self {
self.foreign_device = Some(config);
self
}
/// Builds the concrete B/IP transport with all pre-start controls applied.
///
/// Returns a typed [`Error::Encoding`]
/// when BBMD-dependent controls are set without
/// [`enable_bbmd`](Self::enable_bbmd), or a source Audit route data
/// is set (it requires [`build_session`](Self::build_session), not a bare
/// transport that would discard the endpoint-owned configuration).
pub fn build_transport(self) -> Result<BipTransport, Error> {
if self.registered_network_port.is_some() {
return Err(Error::Encoding(
"Network Port registration requires build_session()".into(),
));
}
if self.device_write_authorizer.is_some() {
return Err(Error::Encoding(
"Device writes require build_session()".into(),
));
}
if !self.source_audit_bindings.is_empty() {
return Err(Error::Encoding(
"source Audit route data requires build_session()".into(),
));
}
let Self {
interface,
port,
broadcast_address,
bbmd_bdt,
foreign_policy,
management_acl,
bdt_persist_path,
fanout_policy,
foreign_device,
..
} = self;
let mut transport = BipTransport::new(interface, port, broadcast_address);
if let Some(bdt) = bbmd_bdt {
transport.enable_bbmd(bdt);
if let Some(policy) = foreign_policy {
transport.enable_foreign_device_registration(policy);
}
if let Some(acl) = management_acl {
transport.set_bbmd_management_acl(acl);
}
} else if foreign_policy.is_some() || management_acl.is_some() {
return Err(Error::Encoding(
"B/IP endpoint: BBMD controls require enable_bbmd() first".into(),
));
}
if let Some(path) = bdt_persist_path {
transport.set_bdt_persist_path(path);
}
if let Some(policy) = fanout_policy {
transport.set_fanout_policy(policy);
}
if let Some(config) = foreign_device {
transport.register_as_foreign_device(config);
}
Ok(transport)
}
/// Builds an unstarted session (caller drives `start()`/`stop()` once).
///
/// One-socket proof: this builds exactly one [`BipTransport`] (one UDP
/// socket after `start()`); no second hidden socket is created here or
/// in [`EndpointSession`]. Bind-count proofs use a counting test double
/// plus real-socket corroboration (single nonzero local MAC/port).
pub fn build_session(mut self) -> Result<EndpointSession<BipTransport>, Error> {
let device_write_authorizer = self.device_write_authorizer.take();
let bindings = std::mem::take(&mut self.source_audit_bindings);
crate::source_audit::recipient::SourceRoutes::new(
&bindings,
SocketAddrV4::new(self.broadcast_address, self.port),
)?;
let role = self.role;
let session = self.session.clone();
let database = self.database.take();
let identity = self.identity.take();
let registered_network_port = self.registered_network_port.take();
let transport = self.build_transport()?;
let mut endpoint = EndpointSession::new(transport, role, session)?;
if let Some(db) = database {
endpoint = endpoint.with_database(db);
}
if let Some(id) = identity {
endpoint = endpoint.with_identity(id);
}
if let Some(authorizer) = device_write_authorizer {
endpoint = endpoint.with_device_writes(authorizer);
}
if let Some(oid) = registered_network_port {
endpoint = endpoint.with_registered_network_port(oid);
}
endpoint.source_audit_bindings = bindings;
Ok(endpoint)
}
}