Compose a shared endpoint
Build and integrate / Shared endpoints
Use an endpoint when one local device needs to initiate requests and serve a bounded request set through the same transport. Keep standalone BACnetClient for a requester or BACnetServer for the full server service set; both remain public APIs.
One session owns the connection
Section titled “One session owns the connection”Rust’s EndpointSession owns transport startup, ingress and shared outbound coordination. Choose ClientOnly, ServerOnly or Both before startup. The role handles use that owner and cannot independently restart its transport.
This complete B/IP program uses synthetic Device instance 1001 and vendor identifier 42, creates both roles on an ephemeral port, and waits for Enter or stdin EOF before explicitly stopping the owner. It demonstrates startup, role availability and socket cleanup; it does not exercise a BACnet request/reply, discovery, a property write or Audit reporting.
The announced interface and configured broadcast destination are 127.0.0.1. The default B/IP OS socket still binds wildcard (0.0.0.0), so this is not a loopback-confined listener. Run this synthetic example on an appropriate test machine; setting the announced interface does not change that bind behavior.
Save the program and manifest
Section titled “Save the program and manifest”These files target v0.15.0, whose minimum Rust version is 1.95. Put Cargo.toml in a new directory and main.rs in its src subdirectory. The exact direct BACnet version is pinned; the empty workspace table makes this a standalone Cargo project.
shared-endpoint-guide/ Cargo.toml src/ main.rs[package]name = "shared-endpoint-guide"version = "0.1.0"edition = "2024"rust-version = "1.95"publish = false
[workspace]
[dependencies]bacnet-endpoint = "=0.15.0"tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "io-util"] }//! Start and stop one shared B/IP owner with observable role and socket state.#![forbid(unsafe_code)]
use bacnet_endpoint::{DeviceIdentity, SessionRole, bip::BipEndpointBuilder};use std::io::{self, Write};use std::net::{Ipv4Addr, UdpSocket};use tokio::io::AsyncBufReadExt;
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { if std::env::args_os().len() != 1 { return Err("usage: shared-endpoint-guide (press Enter or send EOF to stop)".into()); } let identity = DeviceIdentity::new(1001, 42)?.with_bip_port(1, 0, Ipv4Addr::LOCALHOST, 0)?; let database = identity.build_database()?; // LOCALHOST is the announced interface and synthetic broadcast destination. // The default OS listener still binds wildcard; it is not loopback-confined. let mut session = BipEndpointBuilder::new(Ipv4Addr::LOCALHOST, 0, Ipv4Addr::LOCALHOST) .role(SessionRole::Both) .database(database) .device(identity) .build_session()?; session.start().await?;
let address = session.bip_local_address();
// Keep reporting and input errors on the same awaited cleanup path. let observed = async { let address = address.ok_or("started endpoint did not publish its B/IP address")?; writeln!(io::stdout(), "Started endpoint: {address}")?; writeln!( io::stdout(), "Roles: requester={} responder={}", session.client().is_some(), session.server().is_some() )?; writeln!(io::stdout(), "Press Enter to stop (or send EOF).")?; io::stdout().flush()?; let mut input = String::new(); tokio::io::BufReader::new(tokio::io::stdin()) .read_line(&mut input) .await?; Ok::<_, Box<dyn std::error::Error>>(()) } .await; session.stop().await?; writeln!( io::stdout(), "Stopped: address={:?} requester={} responder={}", session.bip_local_address(), session.client().is_some(), session.server().is_some() )?; if let Some(address) = address { let rebound = UdpSocket::bind(address)?; writeln!( io::stdout(), "Rebound endpoint: {}; owner running={}", rebound.local_addr()?, session.is_running() )?; } observed?; Ok(())}Run and observe the owner
Section titled “Run and observe the owner”From the directory containing Cargo.toml, run:
cargo runThe first line contains the actual nonzero port assigned by the OS. Press Enter to stop. Redirected stdin EOF also stops the program. A successful run has this shape, using the same assigned port in both address lines:
Started endpoint: 127.0.0.1:<assigned-port>Roles: requester=true responder=truePress Enter to stop (or send EOF).Stopped: address=None requester=false responder=falseRebound endpoint: 127.0.0.1:<assigned-port>; owner running=falseThe role and address observations come from the session APIs. After awaited stop, the program checks the released port by binding a temporary UDP socket and observes the still-live owner again. It then drops that temporary socket. Printing a start address alone does not establish stop or role availability, and role availability does not establish requester/responder traffic. A failure exits unsuccessfully; reporting and stdin errors after startup pass through the awaited-stop path.
The exact source and manifest displayed here are checked with the development integration harness. They are also independently compiled against the published direct dependency and run through the same lifecycle checks. That evidence uses Rust 1.99.0 and a development test harness; it does not establish the release’s minimum compiler, hardware behavior or protocol-service interoperability.
The declared Network Port in the program is not automatically registered. Explicit registration is a separate receiving-port contract. A successful start exposes the actual B/IP address, including the ephemeral port; after stop it is unavailable.
For SC, ScEndpointBuilder composes a caller-dialed TlsWebSocket with build_hub_session under the sc-tls feature. For MS/TP, MstpEndpointBuilder owns one serial link. Python exposes BipEndpoint, ScEndpoint and MstpEndpoint, with async with endpoint, await endpoint.client() and await endpoint.server(); each takes device_write_policy, a callable that decides writes to the local Device under the same fail-closed rules as a server’s mutation_policy callable.
B/IPv6 has no endpoint builder, as it has no server builder. In Rust, compose an EndpointSession over a Bip6Transport (the bacnet-transport/ipv6 feature) and declare its Network Port with DeviceIdentity::with_bip6_port. Build the transport with Some(instance) of the Device the session serves: that instance is the port’s VMAC, and a registered B/IPv6 port refuses to start with another instance or a random VMAC. Python has no B/IPv6 endpoint, and Ethernet has none in either language; use their standalone owners.
Check requester and responder scope separately
Section titled “Check requester and responder scope separately”| Role | Bounded contract |
|---|---|
| Requester reads | ReadProperty, ReadRange and ReadPropertyMultiple; unsegmented requests/responses |
| Requester RPM | 1–64 explicit property occurrences; concrete objects; no ALL, REQUIRED, OPTIONAL or wildcard object selectors |
| Requester writes | Direct IPv4 B/IP WriteProperty only; required commandability declaration; no routed WP or WPM in this profile |
| Default responder | ReadProperty, with Reject/Abort handling; no responder RPM, COV or full-server parity |
| Opt-in responder writes | One built-in local Device’s Description and, with a complete source profile, Audit_Notification_Recipient; mandatory mutation authorizer |
The Device’s advertised services must match the responder. Server-role declarations include RP and cannot add unsupported services; explicit write opt-in advertises RP+WP only after validation. The endpoint responder does not expose Device COV list properties.
A requester write’s commandability is a declaration about the remote property, not permission to write it. Rust requires Commandability::{Commandable, Noncommandable}; Python requires commandability="commandable" or "noncommandable". For a first integration, exercise reads before enabling mutations. Consult request admission limits for the shared budgets.
Add source READ or WRITE Audit reporting deliberately
Section titled “Add source READ or WRITE Audit reporting deliberately”The optional Rust source Reporter profile belongs to this session and currently covers direct IPv4 B/IP. Configure it before startup:
- Put a concrete built-in Device and the selected Audit Reporter in the database. If it holds several Devices, the lowest instance is the local one (details).
- Provision the Device’s typed Audit recipient. A Device recipient needs an immutable
source_audit_device_binding; a direct Address recipient needs none. - Set the Reporter’s audit level and READ/WRITE operation policy.
Monitored_Objectsmust be absent, including no empty or NULL-only list. - Select
with_source_audit_reporter. ABothsession also requires an explicit Device write authorizer.
The canonical source Reporter example supplies the exact setup. Python exposes endpoint requests but no source Reporter configuration.
READ reporting covers RP, ReadRange and RPM. Records retain attempted identities and outcomes; returned values remain caller-only. An RPM operation can produce a separate record for each eligible occurrence. WRITE reporting records eligible attempts across retries, with a complete Target_Value only when its encoding fits 0–32 bytes. It never invents remote Current_Value, target timestamp or proof that a write executed. Notification delivery failure does not replace the request’s result.
This source profile is separate from the full server’s target Reporter ownership, Device recipient and Audit Log forwarding contracts.
Close the owner, including after cancellation
Section titled “Close the owner, including after cancellation”In Rust, await session.stop(); in Python, await close() or leave the async context. Exported role handles do not keep a closed session usable. Stop owns admitted work and transport cleanup; dropping a caller’s request is not a substitute for stopping the endpoint.
Eligible source-reported requests retain terminal observation after caller cancellation. Other caller-owned requests can cancel queued sends, but an already attempted send may have reached its peer. Cancellation never proves that a remote write did not execute.
Python startup and close serialize through the native lifecycle owner. If startup cancellation races publication, explicitly await close. A canceled admitted close waiter leaves cleanup owned; another awaited close joins it. Successful start consumes registrations, so restart does not replay them. See the full lifecycle contract for startup rollback boundaries.
Next steps
Section titled “Next steps”Register a B/IP receiving port · Compare transport evidence · Exact Rust endpoint APIs
Release v0.15.0 ·Build and integrate. Pre-1.0 APIs; partial conformance.Support & limitations