use std::time::Duration;
use super::address_registry::AddressRegistry;
use super::tunnel::Tunnel;
use crate::error::{Result, TransportError};
use crate::log_transport;
use crate::logging::LogLevel;
use crate::protocol::address::IndividualAddress;
use crate::protocol::cemi::CemiFrame;
use crate::protocol::knxip::{KnxIpFrame, ServiceType, TunnellingRequest};
use crate::protocol::management::DeviceDescriptorRead;
use crate::protocol::tpci::TpciFrame;
const MC_L_DATA_REQ: u8 = 0x11;
const CTRL1_STD_NORMAL: u8 = 0xB4;
const CTRL2_INDIVIDUAL_HOP6: u8 = 0x60;
const PROBE_TIMEOUT: Duration = Duration::from_secs(6);
const AUTO_SELECT_FIRST_DEVICE: u8 = 240;
const AUTO_SELECT_LAST_DEVICE: u8 = 254;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProbeSignal {
Occupied,
Other,
}
pub async fn probe_address(conn: &Tunnel, addr: IndividualAddress) -> Result<bool> {
let src = IndividualAddress::broadcast();
let tconnect = build_tconnect_cemi(src, addr);
conn.send_frame(&wrap_cemi(conn, tconnect)).await?;
let ddr = build_ddr_cemi(src, addr);
conn.send_frame(&wrap_cemi(conn, ddr)).await?;
let occupied = match tokio::time::timeout(PROBE_TIMEOUT, recv_until_signal(conn)).await {
Ok(Ok(occupied)) => occupied,
Ok(Err(e)) => return Err(e),
Err(_elapsed) => false,
};
let tdisconnect = build_tdisconnect_cemi(src, addr);
let _ = conn.send_frame(&wrap_cemi(conn, tdisconnect)).await;
log_transport!(
LogLevel::Debug,
"Probing address {}: {}",
addr,
if occupied { "occupied" } else { "free" }
);
Ok(occupied)
}
async fn recv_until_signal(conn: &Tunnel) -> Result<bool> {
loop {
let data = conn.recv_frame().await?;
let Ok(frame) = KnxIpFrame::parse(&data) else {
continue; };
if frame.header.service_type != ServiceType::TunnellingRequest {
continue;
}
let Ok(request) = TunnellingRequest::parse(&frame.body) else {
continue;
};
if conn.send_acks {
let _ = conn
.send_tunnelling_ack(
request.communication_channel_id,
request.sequence_counter,
0,
)
.await;
}
if let Ok(cemi) = parse_probe_cemi(&request.raw_cemi)
&& classify_cemi(&cemi) == ProbeSignal::Occupied
{
return Ok(true);
}
}
}
fn wrap_cemi(conn: &Tunnel, raw_cemi: Vec<u8>) -> Vec<u8> {
let request = TunnellingRequest::new(conn.channel_id(), conn.next_sequence(), raw_cemi);
KnxIpFrame::new(ServiceType::TunnellingRequest, request.serialize()).serialize()
}
fn build_ldata_req_cemi(
src: IndividualAddress,
dst: IndividualAddress,
tpci: u8,
apci: &[u8],
) -> Vec<u8> {
let mut cemi = Vec::with_capacity(10 + apci.len());
cemi.push(MC_L_DATA_REQ);
cemi.push(0x00); cemi.push(CTRL1_STD_NORMAL);
cemi.push(CTRL2_INDIVIDUAL_HOP6);
cemi.extend_from_slice(&src.raw().to_be_bytes());
cemi.extend_from_slice(&dst.raw().to_be_bytes());
cemi.push(apci.len() as u8); cemi.push(tpci);
cemi.extend_from_slice(apci);
cemi
}
fn build_tconnect_cemi(src: IndividualAddress, dst: IndividualAddress) -> Vec<u8> {
build_ldata_req_cemi(src, dst, TpciFrame::Connect.encode(), &[])
}
fn build_tdisconnect_cemi(src: IndividualAddress, dst: IndividualAddress) -> Vec<u8> {
build_ldata_req_cemi(src, dst, TpciFrame::Disconnect.encode(), &[])
}
fn build_ddr_cemi(src: IndividualAddress, dst: IndividualAddress) -> Vec<u8> {
let ddr = DeviceDescriptorRead { descriptor: 0 }.encode();
let tpci = TpciFrame::DataConnected { sequence: 0 }.encode() | (ddr[0] & 0x03);
build_ldata_req_cemi(src, dst, tpci, &ddr[1..])
}
fn parse_probe_cemi(raw: &[u8]) -> Result<CemiFrame> {
let mut frame = CemiFrame::parse(raw)?;
if frame.data_length == 0 && frame.tpci == 0 {
let ai_len = raw[1] as usize;
let ext = usize::from(frame.extended_control_field.is_some());
let npdu_len_idx = 2 + ai_len + 2 + ext + 4;
let tpci_idx = npdu_len_idx + 1;
if tpci_idx < raw.len() {
frame.tpci = raw[tpci_idx];
}
}
Ok(frame)
}
fn classify_cemi(frame: &CemiFrame) -> ProbeSignal {
if (frame.tpci & 0x03) == 0x03 && frame.apci_data.first().is_some_and(|b| (b & 0xC0) == 0x40) {
return ProbeSignal::Occupied;
}
if TpciFrame::decode(frame.tpci) == TpciFrame::Disconnect {
return ProbeSignal::Occupied;
}
ProbeSignal::Other
}
fn candidate_addresses(area: u8, line: u8) -> impl Iterator<Item = IndividualAddress> {
(AUTO_SELECT_FIRST_DEVICE..=AUTO_SELECT_LAST_DEVICE)
.map(move |device| IndividualAddress::new(area, line, device))
}
pub async fn auto_select_address(
conn: &Tunnel,
registry: &AddressRegistry,
area: u8,
line: u8,
) -> Result<IndividualAddress> {
let mut probed_count: usize = 0;
for addr in candidate_addresses(area, line) {
if !registry.is_available(addr) {
log_transport!(
LogLevel::Debug,
"Auto-select: skipping {} (not available in registry)",
addr
);
continue;
}
probed_count += 1;
log_transport!(
LogLevel::Debug,
"Auto-select: probing candidate {} (attempt {})",
addr,
probed_count
);
if !probe_address(conn, addr).await? {
log_transport!(
LogLevel::Info,
"Auto-selected address {} (probed {} candidates)",
addr,
probed_count
);
return Ok(addr);
}
}
Err(TransportError::InvalidConfiguration {
details: format!(
"No free individual address available in range {area}.{line}.{AUTO_SELECT_FIRST_DEVICE}..={area}.{line}.{AUTO_SELECT_LAST_DEVICE} (probed {probed_count} candidates)"
),
}
.into())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::address::{Address, GroupAddress};
use crate::protocol::cemi::MessageCode;
fn src() -> IndividualAddress {
IndividualAddress::broadcast()
}
fn dst() -> IndividualAddress {
IndividualAddress::new(1, 1, 240)
}
#[test]
fn address_probe_tconnect_cemi_layout() {
let cemi = build_tconnect_cemi(src(), dst());
assert_eq!(cemi.len(), 10);
assert_eq!(
cemi[0], MC_L_DATA_REQ,
"message code must be L_Data.req (0x11)"
);
assert_eq!(cemi[8], 0x00, "NPDU length must be 0 for T_Connect");
assert_eq!(cemi[9], 0x80, "TPCI octet must be T_Connect (0x80)");
assert_eq!(u16::from_be_bytes([cemi[6], cemi[7]]), dst().raw());
}
#[test]
fn address_probe_ddr_cemi_layout() {
let cemi = build_ddr_cemi(src(), dst());
assert_eq!(cemi.len(), 11);
assert_eq!(cemi[0], MC_L_DATA_REQ);
assert_eq!(
cemi[8], 0x01,
"NPDU length must be 1 for DeviceDescriptorRead(0)"
);
assert_eq!(
cemi[9], 0x43,
"TPCI octet must be T_Data_Connected(0) | APCI hi = 0x43"
);
assert_eq!(cemi[10], 0x00, "APCI payload byte must be 0x00");
}
#[test]
fn address_probe_tconnect_cemi_is_unparseable_tpci_but_ddr_round_trips() {
let ddr = build_ddr_cemi(src(), dst());
let parsed = CemiFrame::parse(&ddr).unwrap();
assert_eq!(parsed.tpci, 0x43);
assert_eq!(parsed.apci_data, vec![0x00]);
}
#[test]
fn address_probe_classify_device_descriptor_response_is_occupied() {
let raw = build_ldata_req_cemi(src(), dst(), 0x43, &[0x40, 0x07, 0xB0]);
let frame = parse_probe_cemi(&raw).unwrap();
assert_eq!(classify_cemi(&frame), ProbeSignal::Occupied);
}
#[test]
fn address_probe_classify_disconnect_is_occupied() {
let raw = build_tdisconnect_cemi(src(), dst());
let frame = parse_probe_cemi(&raw).unwrap();
assert_eq!(
frame.tpci, 0x81,
"TPCI octet must be recovered for zero-length NPDU"
);
assert_eq!(classify_cemi(&frame), ProbeSignal::Occupied);
}
#[test]
fn address_probe_classify_disconnect_frame_struct_is_occupied() {
let mut frame = CemiFrame::new(
MessageCode::LDataInd,
src(),
Address::Individual(dst()),
vec![],
);
frame.tpci = TpciFrame::Disconnect.encode();
assert_eq!(classify_cemi(&frame), ProbeSignal::Occupied);
}
#[test]
fn address_probe_classify_group_value_is_other() {
let group = Address::Group(GroupAddress::try_from_raw(0x0801).unwrap());
let frame = CemiFrame::new(MessageCode::LDataInd, src(), group, vec![0x00, 0x81]);
assert_eq!(frame.tpci, 0x00);
assert_eq!(classify_cemi(&frame), ProbeSignal::Other);
}
#[test]
fn address_probe_classify_ack_is_other() {
let mut frame = CemiFrame::new(
MessageCode::LDataInd,
src(),
Address::Individual(dst()),
vec![],
);
frame.tpci = TpciFrame::Ack { sequence: 0 }.encode();
assert_eq!(classify_cemi(&frame), ProbeSignal::Other);
}
#[test]
fn auto_select_candidate_addresses_cover_240_to_254() {
let candidates: Vec<IndividualAddress> = candidate_addresses(3, 5).collect();
assert_eq!(candidates.len(), 15);
assert_eq!(
candidates.first().copied(),
Some(IndividualAddress::new(3, 5, 240))
);
assert_eq!(
candidates.last().copied(),
Some(IndividualAddress::new(3, 5, 254))
);
for (offset, addr) in candidates.iter().enumerate() {
assert_eq!(addr.area(), 3);
assert_eq!(addr.line(), 5);
assert_eq!(addr.device(), 240 + offset as u8);
}
}
#[tokio::test]
async fn auto_select_errors_when_all_candidates_unavailable() {
let registry = AddressRegistry::new();
for addr in candidate_addresses(1, 1) {
registry.add_known_occupied(addr);
}
let tunnel = Tunnel::new_udp("127.0.0.1:3671".parse().unwrap());
let result = auto_select_address(&tunnel, ®istry, 1, 1).await;
assert!(result.is_err(), "exhausted range must return an error");
}
}