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
//! Concrete BACnet/SC controls at endpoint level (no generic-trait erasure).
//!
//! Exposes [`ScTransport`] builders + hub handles first-class on the endpoint
//! composition. Loopback composition stays for unit validation.
#![cfg_attr(
feature = "sc-tls",
doc = "With `sc-tls`, [`ScEndpointBuilder::build_hub_session`] composes a session
over a [`TlsWebSocket`](bacnet_transport::sc_tls::TlsWebSocket) connected to a
local constrained-TLS [`ScHub`](bacnet_transport::sc_hub::ScHub) for RB-16 proofs."
)]
#![cfg_attr(
not(feature = "sc-tls"),
doc = "With `sc-tls`, `ScEndpointBuilder::build_hub_session` composes a session
over a `TlsWebSocket` connected to a local constrained-TLS `ScHub` for RB-16 proofs."
)]
//! MS/TP/Ethernet/IPv6 surfaces are out of scope.
//!
//! # Evidence level
//!
//! Real hub-relayed traffic over constrained TLS 1.3 with mutual auth is
//! proven (RB-16): hub relay preserves the originating VMAC with
//! verified-relayed-origin provenance, VMAC+UUID survive reconnect via
//! caller-owned config, and I-Am matches Device ReadProperty. Loopback
//! composition is unit validation only, never instead of the hub proof. There
//! is deliberately no BIPv6/Ethernet builder here: those data links are
//! explicitly unsupported at endpoint level (see the crate root matrix).
//!
//! # UUID ownership
//!
//! The builder neither generates nor stores the device UUID beyond the
//! caller-supplied bytes. Provision 16 bytes durably (Annex AB.1.5.3) and
//! reuse them for the device lifetime; all-zero is rejected before any hub
//! I/O.
use bacnet_objects::database::ObjectDatabase;
use bacnet_transport::sc::{ScReconnectConfig, ScTransport};
use bacnet_transport::sc_frame::Vmac;
use bacnet_types::error::Error;
use crate::session::{EndpointSession, SessionConfig, SessionRole};
/// Concrete SC endpoint builder (Loopback WebSocket for unit composition).
///
/// Real hub dialing previously lived only in `bacnet-client` /
/// `bacnet-server` SC builders; RB-16 adds the hub-dial path here
#[cfg_attr(
feature = "sc-tls",
doc = "([`build_hub_session`](Self::build_hub_session), requires `sc-tls`) so the"
)]
#[cfg_attr(
not(feature = "sc-tls"),
doc = "(`build_hub_session`, requires `sc-tls`) so the"
)]
/// endpoint composition owns the same concrete validation (VMAC reservation,
/// UUID presence, heartbeat timing, reconnect) without hiding behind
/// `impl TransportPort`.
///
/// ```
/// use bacnet_endpoint::sc::ScEndpointBuilder;
///
/// assert!(ScEndpointBuilder::new([0x02; 6], [0x11; 16]).validate_only().is_ok());
/// assert!(ScEndpointBuilder::new([0; 6], [0x11; 16]).validate_only().is_err());
/// ```
pub struct ScEndpointBuilder {
role: SessionRole,
session: SessionConfig,
database: Option<ObjectDatabase>,
identity: Option<crate::identity::DeviceIdentity>,
vmac: Vmac,
device_uuid: [u8; 16],
heartbeat_interval_ms: u64,
heartbeat_timeout_ms: u64,
reconnect: Option<ScReconnectConfig>,
}
impl ScEndpointBuilder {
/// Creates an SC endpoint builder with identity + timing.
///
/// Defaults: [`Both`](SessionRole::Both) roles, 30 s heartbeat interval,
/// 60 s timeout, no reconnect. `vmac` must not be zero/broadcast and
/// `device_uuid` must not be all-zero (checked at every build).
pub fn new(vmac: Vmac, device_uuid: [u8; 16]) -> Self {
Self {
role: SessionRole::Both,
session: SessionConfig::default(),
database: None,
identity: None,
vmac,
device_uuid,
heartbeat_interval_ms: 30_000,
heartbeat_timeout_ms: 60_000,
reconnect: 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.
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 the
/// session builds via [`EndpointSession::new`].
pub fn read_work_limit(mut self, limit: usize) -> Self {
self.session.read_work_limit = limit;
self
}
/// Least time between the client role's confirmed requests to one
/// destination (default 0: no pacing). See
/// [`SessionConfig::min_request_interval_ms`]; more than an hour fails
/// [`build_loopback_session`](Self::build_loopback_session) and
/// `build_hub_session`.
pub fn min_request_interval_ms(mut self, ms: u64) -> Self {
self.session.min_request_interval_ms = ms;
self
}
/// Attaches the object database for the server responder.
///
/// Build it from the same identity passed to
/// [`device`](Self::device) so I-Am vs ReadProperty agree.
pub fn database(mut self, db: ObjectDatabase) -> Self {
self.database = Some(db);
self
}
/// Composes the single Device identity (overrides SessionConfig 480).
///
/// The identity VMAC + UUID must equal this builder's `vmac` +
/// `device_uuid` (checked at hub-dial build time). At start, the requested
/// ceiling resolves against the stable local receive capacity. An omitted
/// database generates a Device from the resolved identity; a supplied
/// database is preserved and its selected Device must match. The builder
/// uses the caller-supplied UUID and does not generate or persist one.
pub fn device(mut self, identity: crate::identity::DeviceIdentity) -> Self {
self.identity = Some(identity);
self
}
/// Sets heartbeat interval/timeout (ms).
///
/// Validated without hub I/O: interval must be 3_000..=300_000 and the
/// timeout must exceed it (Annex AB.6.3 range, mirrored from
/// `ScTransport` startup validation).
pub fn heartbeat(mut self, interval_ms: u64, timeout_ms: u64) -> Self {
self.heartbeat_interval_ms = interval_ms;
self.heartbeat_timeout_ms = timeout_ms;
self
}
/// Enables reconnect with the given concrete config.
///
/// Validated without hub I/O; VMAC+UUID survive reconnect via this
/// caller-owned config.
pub fn reconnect(mut self, config: ScReconnectConfig) -> Self {
self.reconnect = Some(config);
self
}
fn validate(&self) -> Result<(), Error> {
if self.vmac == [0; 6] || self.vmac == bacnet_transport::sc_frame::BROADCAST_VMAC {
return Err(Error::Encoding(
"SC endpoint builder: vmac must not be zero or broadcast".into(),
));
}
if self.device_uuid == [0; 16] {
return Err(Error::Encoding(
"SC endpoint builder: device_uuid is required and must not be all zero".into(),
));
}
if let Some(config) = &self.reconnect {
config.validate()?;
}
// Heartbeat timing mirrors `ScTransport` startup validation range
// (Annex AB.6.3, 3..300s interval, timeout > interval): validated
// here without hub I/O so misconfiguration fails fast at composition
// time. Duplicated (not imported) to keep the transport seam narrow.
if !(3_000..=300_000).contains(&self.heartbeat_interval_ms) {
return Err(Error::Encoding(format!(
"SC endpoint builder: heartbeat interval must be 3000..=300000 ms, got {} ms",
self.heartbeat_interval_ms
)));
}
if self.heartbeat_timeout_ms <= self.heartbeat_interval_ms {
return Err(Error::Encoding(format!(
"SC endpoint builder: heartbeat timeout must exceed interval, got interval={} ms timeout={} ms",
self.heartbeat_interval_ms, self.heartbeat_timeout_ms
)));
}
Ok(())
}
/// Validates concrete SC configuration without hub I/O.
///
/// Checks VMAC reservation, UUID presence, heartbeat range, and reconnect
/// config. Returns a typed
/// [`Error::Encoding`] on the first
/// violation, before any dial.
pub fn validate_only(&self) -> Result<(), Error> {
self.validate()
}
/// Builds an unstarted Loopback-SC session for unit composition.
///
/// Uses [`LoopbackWebSocket`](bacnet_transport::sc::LoopbackWebSocket):
/// no TLS, no hub dial; the caller drives the hub side for handshake in
/// unit proofs. RB-15 tests prefer [`LoopbackTransport`](bacnet_transport::loopback::LoopbackTransport)
/// for full session I/O and use this builder for config validation only.
pub fn build_loopback_session(
mut self,
ws: bacnet_transport::sc::LoopbackWebSocket,
) -> Result<EndpointSession<ScTransport<bacnet_transport::sc::LoopbackWebSocket>>, Error> {
self.validate()?;
let vmac = self.vmac;
let uuid = self.device_uuid;
let hb_interval = self.heartbeat_interval_ms;
let hb_timeout = self.heartbeat_timeout_ms;
let reconnect = self.reconnect.take();
let role = self.role;
let session = self.session.clone();
let transport = ScTransport::new(ws, vmac)
.with_device_uuid(uuid)
.with_heartbeat_interval_ms(hb_interval)
.with_heartbeat_timeout_ms(hb_timeout);
let transport = match reconnect {
Some(config) => transport.with_reconnect(config),
None => transport,
};
let mut endpoint = EndpointSession::new(transport, role, session)?;
if let Some(db) = self.database.take() {
endpoint = endpoint.with_database(db);
}
if let Some(id) = self.identity.take() {
endpoint = endpoint.device(id);
}
Ok(endpoint)
}
/// Builds an unstarted real-hub SC session over constrained TLS (RB-16).
///
/// NEW in RB-16: `ScEndpointBuilder` previously offered loopback only.
/// The caller dials first (`TlsWebSocket::connect(url, node_tls).await`)
/// against a local constrained-TLS [`ScHub`](bacnet_transport::sc_hub::ScHub)
/// (rcgen-CA in proofs); this constructor validates VMAC/UUID/heartbeat/
/// reconnect identically to the loopback path, then composes the session
/// above the connected socket. Identity VMAC+UUID must match the builder
/// when an identity is composed; the device UUID stays durable-caller-owned
/// (neither generated nor stored here). One SC node connection per session;
/// kill/reconnect preserves VMAC+UUID via the caller-owned reconnect
/// config (vs transient connections that renegotiate).
#[cfg(feature = "sc-tls")]
#[cfg_attr(docsrs, doc(cfg(feature = "sc-tls")))]
pub fn build_hub_session(
mut self,
ws: bacnet_transport::sc_tls::TlsWebSocket,
) -> Result<EndpointSession<ScTransport<bacnet_transport::sc_tls::TlsWebSocket>>, Error> {
self.validate()?;
if let Some(identity) = self.identity.as_ref() {
// Identity VMAC is the SC port MAC when an SC port is present;
// otherwise the identity carries no VMAC and the builder VMAC wins.
// UUID must always agree when both are present (durable-caller-owned).
if identity.device_uuid() != [0; 16] && identity.device_uuid() != self.device_uuid {
return Err(Error::Encoding(
"SC endpoint: identity device UUID must equal builder device_uuid".into(),
));
}
let sc_macs: Vec<_> = identity
.network_ports()
.iter()
.filter(|p| {
p.network_type == bacnet_types::enums::NetworkType::VIRTUAL.to_raw()
&& p.mac.as_slice().len() == 6
})
.map(|p| p.mac.as_slice().to_vec())
.collect();
if !sc_macs.is_empty() && !sc_macs.iter().any(|m| m.as_slice() == self.vmac) {
return Err(Error::Encoding(
"SC endpoint: identity SC port VMAC must equal builder vmac".into(),
));
}
}
let vmac = self.vmac;
let uuid = self.device_uuid;
let hb_interval = self.heartbeat_interval_ms;
let hb_timeout = self.heartbeat_timeout_ms;
let reconnect = self.reconnect.take();
let role = self.role;
let session = self.session.clone();
let transport = ScTransport::new(ws, vmac)
.with_device_uuid(uuid)
.with_heartbeat_interval_ms(hb_interval)
.with_heartbeat_timeout_ms(hb_timeout);
let transport = match reconnect {
Some(config) => transport.with_reconnect(config),
None => transport,
};
let mut endpoint = EndpointSession::new(transport, role, session)?;
if let Some(db) = self.database.take() {
endpoint = endpoint.with_database(db);
}
if let Some(id) = self.identity.take() {
endpoint = endpoint.device(id);
}
Ok(endpoint)
}
}