bacnet-client 0.15.0

BACnet async client: TSM, segmentation, discovery, and service APIs
Documentation
use super::*;
use bacnet_types::enums::ObjectType;

fn local_device(instance: u32, mac: &[u8], last_seen: Instant) -> DiscoveredDevice {
    DiscoveredDevice {
        object_identifier: ObjectIdentifier::new(ObjectType::DEVICE, instance).unwrap(),
        mac_address: MacAddr::from_slice(mac),
        max_apdu_length: 1476,
        segmentation_supported: Segmentation::NONE,
        max_segments_accepted: None,
        vendor_id: 42,
        last_seen,
        source_network: None,
        source_address: None,
    }
}

fn routed_device(
    instance: u32,
    router_mac: &[u8],
    network: u16,
    address: &[u8],
    last_seen: Instant,
) -> DiscoveredDevice {
    let mut device = local_device(instance, router_mac, last_seen);
    device.source_network = Some(network);
    device.source_address = Some(MacAddr::from_slice(address));
    device
}

fn assert_same_device(actual: &DiscoveredDevice, expected: &DiscoveredDevice) {
    assert_eq!(actual.object_identifier, expected.object_identifier);
    assert_eq!(actual.mac_address, expected.mac_address);
    assert_eq!(actual.max_apdu_length, expected.max_apdu_length);
    assert_eq!(
        actual.segmentation_supported,
        expected.segmentation_supported
    );
    assert_eq!(actual.max_segments_accepted, expected.max_segments_accepted);
    assert_eq!(actual.vendor_id, expected.vendor_id);
    assert_eq!(actual.last_seen, expected.last_seen);
    assert_eq!(actual.source_network, expected.source_network);
    assert_eq!(actual.source_address, expected.source_address);
}

fn retained_from_collision(result: DeviceUpsertResult) -> DiscoveredDevice {
    match result {
        DeviceUpsertResult::Collision { retained } => retained,
        other => panic!("expected collision, got {other:?}"),
    }
}

#[test]
fn first_i_am_inserts_and_same_local_endpoint_refreshes_metadata() {
    let now = Instant::now();
    let mut table = DeviceTable::new();
    let original = local_device(1001, &[0x01], now);

    assert!(matches!(
        table.upsert_with_result(original.clone()),
        DeviceUpsertResult::Inserted
    ));

    let mut refreshed = original;
    refreshed.max_apdu_length = 480;
    refreshed.segmentation_supported = Segmentation::BOTH;
    refreshed.max_segments_accepted = Some(8);
    refreshed.vendor_id = 84;
    refreshed.last_seen = now + Duration::from_secs(10);
    assert!(matches!(
        table.upsert_with_result(refreshed.clone()),
        DeviceUpsertResult::Updated
    ));
    assert_same_device(table.get(1001).unwrap(), &refreshed);
}

#[test]
fn different_local_endpoint_collides_and_retains_exact_row() {
    let now = Instant::now();
    let mut table = DeviceTable::new();
    let retained = local_device(1001, &[0x01], now);
    table.upsert_with_result(retained.clone());

    let mut incoming = local_device(1001, &[0x02], now + Duration::from_secs(10));
    incoming.vendor_id = 84;
    let collision = retained_from_collision(table.upsert_with_result(incoming));

    assert_same_device(&collision, &retained);
    assert_same_device(table.get(1001).unwrap(), &retained);
}

#[test]
fn same_routed_endpoint_refreshes_router_and_metadata() {
    let now = Instant::now();
    let mut table = DeviceTable::new();
    let original = routed_device(1001, &[0x10], 100, &[0x03], now);
    table.upsert_with_result(original.clone());

    let mut refreshed = routed_device(1001, &[0x20], 100, &[0x03], now + Duration::from_secs(10));
    refreshed.max_apdu_length = 480;
    refreshed.segmentation_supported = Segmentation::BOTH;
    refreshed.vendor_id = 84;
    assert!(matches!(
        table.upsert_with_result(refreshed.clone()),
        DeviceUpsertResult::Updated
    ));
    assert_same_device(table.get(1001).unwrap(), &refreshed);
}

#[test]
fn differing_complete_endpoint_identities_collide() {
    let now = Instant::now();
    let routed = routed_device(1001, &[0x10], 100, &[0x03], now);
    let mut table = DeviceTable::new();
    table.upsert_with_result(routed.clone());

    for incoming in [
        routed_device(1001, &[0x20], 200, &[0x03], now),
        routed_device(1001, &[0x20], 100, &[0x04], now),
    ] {
        let collision = retained_from_collision(table.upsert_with_result(incoming));
        assert_same_device(&collision, &routed);
        assert_same_device(table.get(1001).unwrap(), &routed);
    }

    let local = local_device(2002, &[0x01], now);
    let mut local_table = DeviceTable::new();
    local_table.upsert_with_result(local.clone());
    let collision = retained_from_collision(local_table.upsert_with_result(routed_device(
        2002,
        &[0x10],
        100,
        &[0x03],
        now,
    )));
    assert_same_device(&collision, &local);

    let routed = routed_device(3003, &[0x10], 100, &[0x03], now);
    let mut routed_table = DeviceTable::new();
    routed_table.upsert_with_result(routed.clone());
    let collision =
        retained_from_collision(routed_table.upsert_with_result(local_device(3003, &[0x01], now)));
    assert_same_device(&collision, &routed);
}

#[test]
fn placeholder_and_partial_rows_remain_repairable_by_complete_i_am() {
    let now = Instant::now();
    let mut placeholder_table = DeviceTable::new();
    placeholder_table.upsert_placeholder(local_device(1001, &[0x01], now));
    let replacement = local_device(1001, &[0x02], now + Duration::from_secs(10));
    assert!(matches!(
        placeholder_table.upsert_with_result(replacement.clone()),
        DeviceUpsertResult::Updated
    ));
    assert_same_device(placeholder_table.get(1001).unwrap(), &replacement);

    let mut partial = local_device(2002, &[0x01], now);
    partial.source_network = Some(100);
    let mut partial_table = DeviceTable::new();
    partial_table.upsert(partial);
    let replacement = routed_device(2002, &[0x10], 200, &[0x03], now);
    assert!(matches!(
        partial_table.upsert_with_result(replacement.clone()),
        DeviceUpsertResult::Updated
    ));
    assert_same_device(partial_table.get(2002).unwrap(), &replacement);
}

#[test]
fn partial_incoming_row_cannot_displace_stable_authority() {
    let now = Instant::now();
    let retained = local_device(1001, &[0x01], now);
    let mut table = DeviceTable::new();
    table.upsert_with_result(retained.clone());

    let mut partial = local_device(1001, &[0x01], now + Duration::from_secs(10));
    partial.source_address = Some(MacAddr::from_slice(&[0x03]));
    let collision = retained_from_collision(table.upsert_with_result(partial));

    assert_same_device(&collision, &retained);
    assert_same_device(table.get(1001).unwrap(), &retained);
}

#[test]
fn explicit_manual_upsert_overwrites_different_endpoint() {
    let now = Instant::now();
    let mut table = DeviceTable::new();
    table.upsert_with_result(local_device(1001, &[0x01], now));
    let manual = routed_device(1001, &[0x10], 100, &[0x03], now);

    table.upsert(manual.clone());

    assert_same_device(table.get(1001).unwrap(), &manual);
}

#[test]
fn repeated_collisions_do_not_refresh_and_claimant_can_insert_after_purge() {
    let now = Instant::now();
    let retained = local_device(1001, &[0x01], now);
    let mut table = DeviceTable::new();
    table.upsert_with_result(retained.clone());

    let mut claimant = local_device(1001, &[0x02], now + Duration::from_secs(10));
    for vendor_id in [84, 85] {
        claimant.vendor_id = vendor_id;
        claimant.last_seen += Duration::from_secs(10);
        let collision = retained_from_collision(table.upsert_with_result(claimant.clone()));
        assert_same_device(&collision, &retained);
        assert_same_device(table.get(1001).unwrap(), &retained);
    }

    let removed = table.purge_stale_at(now + Duration::from_secs(61), Duration::from_secs(60));
    assert_eq!(removed.len(), 1);
    assert!(matches!(
        table.upsert_with_result(claimant.clone()),
        DeviceUpsertResult::Inserted
    ));
    assert_same_device(table.get(1001).unwrap(), &claimant);
}