use super::binding_probes::{BindingProbes, WhoIsScope};
use super::event_recipient_route::RecipientRoute;
use super::*;
use bacnet_encoding::primitives::{
encode_app_object_id, encode_app_octet_string, encode_app_unsigned,
};
pub(super) const MAX_DEVICE_BINDINGS: usize = 4096;
pub(super) const OBSERVED_BINDING_TTL: Duration = Duration::from_secs(10 * 60);
#[derive(Debug, Clone, PartialEq, Eq)]
enum DeviceBindingTarget {
Local {
peer_mac: MacAddr,
},
Routed {
network: u16,
final_mac: MacAddr,
router_mac: MacAddr,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeviceBinding {
device: ObjectIdentifier,
target: DeviceBindingTarget,
}
impl DeviceBinding {
pub fn local(device: ObjectIdentifier, peer_mac: impl AsRef<[u8]>) -> Result<Self, Error> {
validate_device_identifier(device)?;
let peer_mac = MacAddr::from_slice(peer_mac.as_ref());
if peer_mac.is_empty() {
return Err(binding_error("local peer MAC must be non-empty"));
}
Ok(Self {
device,
target: DeviceBindingTarget::Local { peer_mac },
})
}
pub fn routed(
device: ObjectIdentifier,
network: u16,
final_mac: impl AsRef<[u8]>,
router_mac: impl AsRef<[u8]>,
) -> Result<Self, Error> {
validate_device_identifier(device)?;
if !(1..=0xFFFE).contains(&network) {
return Err(binding_error("routed network must be in 1..=65534"));
}
let final_mac = MacAddr::from_slice(final_mac.as_ref());
if final_mac.is_empty() {
return Err(binding_error("routed final MAC must be non-empty"));
}
let router_mac = MacAddr::from_slice(router_mac.as_ref());
if router_mac.is_empty() {
return Err(binding_error("routed router MAC must be non-empty"));
}
Ok(Self {
device,
target: DeviceBindingTarget::Routed {
network,
final_mac,
router_mac,
},
})
}
}
fn binding_error(message: &str) -> Error {
Error::Encoding(format!("invalid device binding: {message}"))
}
fn validate_device_identifier(device: ObjectIdentifier) -> Result<(), Error> {
if device.object_type() != ObjectType::DEVICE {
return Err(binding_error("identifier must name a Device object"));
}
Ok(())
}
pub(super) fn register_configured_binding(
bindings: &mut Vec<DeviceBinding>,
binding: DeviceBinding,
) -> Result<(), Error> {
if bindings.len() >= MAX_DEVICE_BINDINGS {
return Err(binding_error("configured binding capacity exceeded"));
}
if bindings
.iter()
.any(|configured| configured.device == binding.device)
{
return Err(binding_error("duplicate configured Device identifier"));
}
bindings.push(binding);
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum DeviceResolution {
ResolvedLocal {
peer_mac: MacAddr,
freshness: BindingFreshness,
},
ResolvedRouted {
network: u16,
final_mac: MacAddr,
router_mac: MacAddr,
freshness: BindingFreshness,
},
Unknown,
Stale,
Invalid,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum BindingFreshness {
Configured,
ObservedUntil(tokio::time::Instant),
}
impl BindingFreshness {
pub(super) fn permits_attempt_at(self, now: tokio::time::Instant) -> bool {
match self {
Self::Configured => true,
Self::ObservedUntil(deadline) => now < deadline,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ObservationOutcome {
Inserted,
Refreshed,
ConfiguredPreserved,
RejectedInvalid,
RejectedCapacity,
}
#[derive(Debug, Clone)]
enum BindingEntry {
Configured(DeviceBindingTarget),
Observed {
target: DeviceBindingTarget,
observed_at: Instant,
},
}
#[derive(Debug, Default)]
pub(super) struct DeviceBindingTable {
entries: HashMap<ObjectIdentifier, BindingEntry>,
pub(super) probes: BindingProbes,
}
impl DeviceBindingTable {
pub(super) fn command_origin(
&self,
immediate: &[u8],
routed: Option<&NpduAddress>,
local_network: Option<u16>,
is_group: impl Fn(&[u8]) -> bool,
) -> bacnet_objects::command_source::CommandOrigin {
bacnet_objects::command_source::CommandOrigin::Remote {
actual_address: bacnet_types::constructed::BACnetAddress {
network_number: routed.map_or(0, |source| source.network),
mac_address: routed.map_or_else(
|| MacAddr::from_slice(immediate),
|source| source.mac_address.clone(),
),
},
binding: self.source_binding(immediate, routed, local_network, is_group),
}
}
pub(super) fn source_device(
&self,
immediate: &[u8],
routed: Option<&NpduAddress>,
local_network: Option<u16>,
is_group: impl Fn(&[u8]) -> bool,
) -> Option<ObjectIdentifier> {
match self.source_binding(immediate, routed, local_network, is_group) {
bacnet_objects::command_source::CommandDeviceBinding::Unique(device) => Some(device),
_ => None,
}
}
pub(super) fn source_binding(
&self,
immediate: &[u8],
routed: Option<&NpduAddress>,
local_network: Option<u16>,
is_group: impl Fn(&[u8]) -> bool,
) -> bacnet_objects::command_source::CommandDeviceBinding {
use bacnet_objects::command_source::CommandDeviceBinding;
let now = runtime_clock::now();
let mut matched = None;
for device in self.entries.keys() {
let resolution = self.resolve_at(device, now, &is_group);
let route = RecipientRoute::from_device_resolution(resolution).localize(
local_network,
&is_group,
&is_group,
);
let matches = match (route, routed) {
(RecipientRoute::BoundLocalUnicast { mac, .. }, None) => {
mac.as_slice() == immediate
}
(RecipientRoute::BoundLocalUnicast { mac, .. }, Some(source)) => {
Some(source.network) == local_network && mac == source.mac_address
}
(RecipientRoute::BoundRoutedUnicast { network, mac, .. }, Some(source)) => {
network == source.network && mac == source.mac_address
}
_ => false,
};
if matches {
if matched.is_some() {
return CommandDeviceBinding::Ambiguous;
}
matched = Some(*device);
}
}
matched.map_or(CommandDeviceBinding::Unknown, CommandDeviceBinding::Unique)
}
pub(super) fn new() -> Self {
Self::default()
}
pub(super) fn from_configured(
bindings: Vec<DeviceBinding>,
is_group: impl Fn(&[u8]) -> bool,
) -> Result<Self, Error> {
let mut table = Self::new();
for binding in bindings {
table.insert_configured(binding, &is_group)?;
}
Ok(table)
}
pub(super) fn configured_resolutions(
&self,
) -> impl Iterator<Item = (ObjectIdentifier, DeviceResolution)> + '_ {
self.entries
.iter()
.filter(|(_, entry)| matches!(entry, BindingEntry::Configured(_)))
.map(|(device, _)| {
(
*device,
self.resolve_at(device, runtime_clock::now(), |_| false),
)
})
}
#[cfg(test)]
pub(super) fn len(&self) -> usize {
self.entries.len()
}
pub(super) fn insert_configured(
&mut self,
binding: DeviceBinding,
is_group: impl Fn(&[u8]) -> bool,
) -> Result<(), Error> {
validate_device_identifier(binding.device)?;
if let Some((role, mac)) = group_hop(&binding.target, &is_group) {
return Err(binding_error(&format!(
"Device {} is bound at {} as {role}, a broadcast or group address of \
this link; bind the device's unicast address",
binding.device.instance_number(),
colon_hex(mac)
)));
}
if !target_is_usable(&binding.target, &is_group) {
return Err(binding_error("the binding names no reachable device"));
}
if self.entries.contains_key(&binding.device) {
return Err(binding_error("duplicate configured Device identifier"));
}
if self.entries.len() >= MAX_DEVICE_BINDINGS {
return Err(binding_error("configured binding capacity exceeded"));
}
self.entries
.insert(binding.device, BindingEntry::Configured(binding.target));
Ok(())
}
pub(super) fn observe_i_am_at(
&mut self,
device: ObjectIdentifier,
source_mac: &[u8],
source_network: Option<&NpduAddress>,
now: Instant,
is_group: impl Fn(&[u8]) -> bool,
) -> ObservationOutcome {
if validate_device_identifier(device).is_err() {
return ObservationOutcome::RejectedInvalid;
}
let target = match source_network {
None => DeviceBindingTarget::Local {
peer_mac: MacAddr::from_slice(source_mac),
},
Some(source)
if (1..=0xFFFE).contains(&source.network) && !source.mac_address.is_empty() =>
{
DeviceBindingTarget::Routed {
network: source.network,
final_mac: source.mac_address.clone(),
router_mac: MacAddr::from_slice(source_mac),
}
}
Some(_) => return ObservationOutcome::RejectedInvalid,
};
if !target_is_usable(&target, &is_group) {
return ObservationOutcome::RejectedInvalid;
}
let outcome = self.record_observation(device, target, now);
if matches!(
outcome,
ObservationOutcome::Inserted | ObservationOutcome::Refreshed
) {
self.probes.answer(&device);
}
outcome
}
fn record_observation(
&mut self,
device: ObjectIdentifier,
target: DeviceBindingTarget,
now: Instant,
) -> ObservationOutcome {
match self.entries.get_mut(&device) {
Some(BindingEntry::Configured(_)) => ObservationOutcome::ConfiguredPreserved,
Some(BindingEntry::Observed {
target: observed,
observed_at,
}) => {
*observed = target;
*observed_at = now;
ObservationOutcome::Refreshed
}
None => {
if self.entries.len() >= MAX_DEVICE_BINDINGS {
self.entries.retain(|_, entry| match entry {
BindingEntry::Configured(_) => true,
BindingEntry::Observed { observed_at, .. } => {
now.saturating_duration_since(*observed_at) < OBSERVED_BINDING_TTL
}
});
}
if self.entries.len() >= MAX_DEVICE_BINDINGS {
return ObservationOutcome::RejectedCapacity;
}
self.entries.insert(
device,
BindingEntry::Observed {
target,
observed_at: now,
},
);
ObservationOutcome::Inserted
}
}
}
pub(crate) fn address_binding_list(&self, now: Instant) -> PropertyValue {
let mut bindings: Vec<_> = self
.entries
.iter()
.filter_map(|(device, entry)| {
let target = match entry {
BindingEntry::Configured(target) => target,
BindingEntry::Observed {
target,
observed_at,
} => {
if now.saturating_duration_since(*observed_at) >= OBSERVED_BINDING_TTL {
return None;
}
target
}
};
Some((*device, target))
})
.collect();
bindings.sort_by_key(|(device, _)| device.instance_number());
PropertyValue::List(
bindings
.into_iter()
.map(|(device, target)| {
let (network, mac) = match target {
DeviceBindingTarget::Local { peer_mac } => (0, peer_mac),
DeviceBindingTarget::Routed {
network, final_mac, ..
} => (*network, final_mac),
};
let mut encoded = BytesMut::new();
encode_app_object_id(&mut encoded, &device);
encode_app_unsigned(&mut encoded, u64::from(network));
encode_app_octet_string(&mut encoded, mac);
PropertyValue::ApplicationData(encoded.to_vec())
})
.collect(),
)
}
pub(super) fn who_is_scope(&self, device: &ObjectIdentifier) -> WhoIsScope {
match self.entries.get(device) {
Some(BindingEntry::Observed {
target: DeviceBindingTarget::Local { .. },
..
}) => WhoIsScope::Local,
Some(BindingEntry::Observed {
target: DeviceBindingTarget::Routed { network, .. },
..
}) => WhoIsScope::Remote(*network),
Some(BindingEntry::Configured(_)) | None => WhoIsScope::Global,
}
}
pub(super) fn forget_stale(&mut self, device: &ObjectIdentifier, now: Instant) {
if let Some(BindingEntry::Observed { observed_at, .. }) = self.entries.get(device)
&& now.saturating_duration_since(*observed_at) >= OBSERVED_BINDING_TTL
{
self.entries.remove(device);
}
}
pub(super) fn resolve_at(
&self,
device: &ObjectIdentifier,
now: Instant,
is_group: impl Fn(&[u8]) -> bool,
) -> DeviceResolution {
if device.object_type() != ObjectType::DEVICE {
return DeviceResolution::Invalid;
}
let (target, freshness) = match self.entries.get(device) {
Some(BindingEntry::Configured(target)) => (target, BindingFreshness::Configured),
Some(BindingEntry::Observed {
target,
observed_at,
}) => {
if now.saturating_duration_since(*observed_at) >= OBSERVED_BINDING_TTL {
return DeviceResolution::Stale;
}
(
target,
BindingFreshness::ObservedUntil(tokio::time::Instant::from_std(
*observed_at + OBSERVED_BINDING_TTL,
)),
)
}
None => return DeviceResolution::Unknown,
};
if !target_is_usable(target, is_group) {
return DeviceResolution::Invalid;
}
match target {
DeviceBindingTarget::Local { peer_mac } => DeviceResolution::ResolvedLocal {
peer_mac: peer_mac.clone(),
freshness,
},
DeviceBindingTarget::Routed {
network,
final_mac,
router_mac,
} => DeviceResolution::ResolvedRouted {
network: *network,
final_mac: final_mac.clone(),
router_mac: router_mac.clone(),
freshness,
},
}
}
}
fn group_hop(
target: &DeviceBindingTarget,
is_group: impl Fn(&[u8]) -> bool,
) -> Option<(&'static str, &MacAddr)> {
match target {
DeviceBindingTarget::Local { peer_mac } => {
is_group(peer_mac).then_some(("its own MAC", peer_mac))
}
DeviceBindingTarget::Routed { router_mac, .. } => {
is_group(router_mac).then_some(("its router's MAC", router_mac))
}
}
}
fn colon_hex(mac: &[u8]) -> String {
mac.iter()
.map(|octet| format!("{octet:02x}"))
.collect::<Vec<_>>()
.join(":")
}
fn target_is_usable(target: &DeviceBindingTarget, is_group: impl Fn(&[u8]) -> bool) -> bool {
match target {
DeviceBindingTarget::Local { peer_mac } => !peer_mac.is_empty() && !is_group(peer_mac),
DeviceBindingTarget::Routed {
network,
final_mac,
router_mac,
} => {
(1..=0xFFFE).contains(network)
&& !final_mac.is_empty()
&& !router_mac.is_empty()
&& !is_group(router_mac)
}
}
}
pub(super) async fn snapshot_command_origin(
service: ConfirmedServiceChoice,
immediate: &[u8],
routed: Option<&NpduAddress>,
local_network: Option<u16>,
bindings: &Arc<RwLock<DeviceBindingTable>>,
transactions: &Arc<NotificationTransactions>,
) -> Option<bacnet_objects::command_source::CommandOrigin> {
if !matches!(
service,
ConfirmedServiceChoice::WRITE_PROPERTY
| ConfirmedServiceChoice::WRITE_PROPERTY_MULTIPLE
| ConfirmedServiceChoice::CREATE_OBJECT
) {
return None;
}
Some(
bindings
.read()
.await
.command_origin(immediate, routed, local_network, |mac| {
transactions
.audit_routes
.get()
.is_some_and(|routes| routes.is_group(mac))
}),
)
}
#[cfg(test)]
#[path = "group_binding_tests.rs"]
mod group_binding_tests;