use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use bacnet_client::client::BACnetClient;
use bacnet_services::who_is::WhoIsRequest;
use bacnet_transport::port::TransportPort;
use bacnet_transport::sc::ScEvent;
use bacnet_types::enums::UnconfirmedServiceChoice;
use bacnet_types::error::Error;
use bytes::BytesMut;
use tokio::sync::{broadcast, mpsc};
use tokio::task::JoinHandle;
use tokio::time::Instant;
use super::browse::limits::RateCap;
use super::watch::WatchOptions;
use super::write::SafetyOptions;
use super::{passive, DeviceFeed, EventSink, COMMAND_CHANNEL_CAPACITY};
use crate::core::target::AddressStyle;
use crate::transport::{self, TransportArgs};
use crate::tui::message::{
Command, DirectedTo, OpId, OpOutcome, WhoIsScope, WhoIsSpec, WorkerEvent,
};
use crate::tui::network::Foreign;
use crate::tui::point::BrowseResult;
#[cfg(feature = "sc-tls")]
use crate::tui::sc::{credentials::Credentials, ScFailure, ScIdentity, ScReport};
const RESYNC_EVERY: Duration = Duration::from_secs(1);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TransportKind {
Bip,
Bip6,
Sc,
Mstp,
MstpMonitor,
}
impl TransportKind {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Bip => "BIP",
Self::Bip6 => "BIP6",
Self::Sc => "SC",
Self::Mstp | Self::MstpMonitor => "MSTP",
}
}
pub(crate) fn style(self) -> AddressStyle {
match self {
Self::Bip => AddressStyle::Bip,
Self::Bip6 => AddressStyle::Bip6,
Self::Sc => AddressStyle::Hex,
Self::Mstp | Self::MstpMonitor => AddressStyle::Mstp,
}
}
}
#[derive(Clone)]
pub(crate) struct ConnectPlan {
pub(crate) kind: TransportKind,
pub(crate) args: TransportArgs,
pub(crate) await_interface: bool,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct SessionOptions {
pub(crate) max_rate: u32,
pub(crate) watch: WatchOptions,
pub(crate) safety: SafetyOptions,
pub(crate) mstp: crate::tui::monitor::MstpOptions,
}
const WATCH_CHANNEL_CAPACITY: usize = 128;
pub(crate) fn spawn(
plan: ConnectPlan,
sink: EventSink,
commands: mpsc::Receiver<Command>,
options: SessionOptions,
) -> JoinHandle<()> {
tokio::spawn(Box::pin(connect(plan, sink, commands, options)))
}
async fn connect(
plan: ConnectPlan,
sink: EventSink,
commands: mpsc::Receiver<Command>,
mut options: SessionOptions,
) {
if plan.kind == TransportKind::MstpMonitor {
return match options.mstp.monitor.clone() {
Some(monitor) => {
let lines = &options.mstp.lines;
Box::pin(crate::tui::monitor::worker::serve(
monitor, lines, &sink, commands,
))
.await
}
None => failed(&sink, Error::Encoding("no MS/TP port to monitor".into())).await,
};
}
if options.safety.passive {
return Box::pin(passive::serve(&sink, commands, &options.safety)).await;
}
let args = plan.args;
options.watch.request_budget = Duration::from_millis(args.timeout_ms) * 4;
let style = plan.kind.style();
match plan.kind {
TransportKind::Bip => {
if let Some(mut client) =
finish_build(Box::pin(transport::build_bip_client(&args)), &sink).await
{
if let Some(foreign) = &args.foreign_device {
let bbmd = std::net::SocketAddrV4::new(foreign.bbmd_ip, foreign.bbmd_port);
let outcome = tokio::select! {
biased;
() = sink.cancelled() => None,
result = crate::foreign::await_registration(&client, bbmd) => Some(result),
};
let Some(outcome) = outcome else {
if let Err(error) = client.stop().await {
tracing::warn!("client stop: {error}");
}
return;
};
match outcome {
Ok(()) => tracing::info!("BBMD {bbmd} accepted the foreign-device registration; it renews automatically"),
Err(error) => tracing::warn!("{error}; retrying in the background"),
}
let renewal = foreign
.renewal_interval
.unwrap_or(Duration::from_secs(u64::from(foreign.ttl)) / 2);
let foreign = Foreign {
bbmd,
ttl: foreign.ttl,
renewal,
};
sink.deliver(WorkerEvent::ForeignDevice(foreign)).await;
}
Box::pin(serve(client, style, sink, commands, options)).await;
}
}
TransportKind::Bip6 => {
if let Some(client) =
finish_build(Box::pin(transport::build_bip6_client(&args)), &sink).await
{
Box::pin(serve(client, style, sink, commands, options)).await;
}
}
#[cfg(feature = "sc-tls")]
TransportKind::Sc => {
if let Some(identity) = sc_identity(&args) {
sink.deliver(WorkerEvent::Sc(ScReport::Dialing(identity)))
.await;
}
if let Some(credentials) = sc_credentials(&args) {
let report = ScReport::Credentials(Box::new(credentials));
sink.deliver(WorkerEvent::Sc(report)).await;
}
let mut events = None;
let build = async {
let built = match transport::sc_client_builder(&args) {
Ok(builder) => builder.build_with_events().await,
Err(error) => Err(error),
};
match built {
Ok((client, subscribed)) => {
events = Some(subscribed);
Ok(client)
}
Err(error) => {
let failure = Box::new(ScFailure::from_error(&error));
sink.deliver(WorkerEvent::Sc(ScReport::Failed(failure)))
.await;
Err(error)
}
}
};
if let Some(client) = finish_build(Box::pin(build), &sink).await {
let serving = serve_with(client, style, sink, commands, options, events);
Box::pin(serving).await;
}
}
#[cfg(not(feature = "sc-tls"))]
TransportKind::Sc => {
failed(
&sink,
Error::Encoding("BACnet/SC requires the 'sc-tls' feature".into()),
)
.await;
}
TransportKind::Mstp => {
let mut handle = None;
let lines = options.mstp.lines.clone();
let thread = options.mstp.thread.clone();
let build = async {
let (client, diagnostics) = lines.participant(&args, &thread).await?;
handle = Some(diagnostics);
Ok(client)
};
let built = finish_build(Box::pin(build), &sink).await;
if let (Some(client), Some(handle), Some(station)) = (built, handle, args.mstp.clone())
{
tracing::info!(
"joined the MS/TP trunk on {} as station {}",
station.port,
station.mac
);
let thread = crate::mstp_thread::status(handle.execution_status().as_ref());
let report = crate::tui::mstp::MstpReport::Joined(station, thread);
sink.deliver(WorkerEvent::Mstp(report)).await;
let sampler = crate::tui::mstp::sample(handle, sink.clone());
tokio::select! {
biased;
() = Box::pin(serve(client, style, sink, commands, options)) => {}
() = sampler => {}
}
}
}
TransportKind::MstpMonitor => {}
}
}
#[cfg(feature = "sc-tls")]
fn sc_identity(args: &TransportArgs) -> Option<ScIdentity> {
Some(ScIdentity {
primary: args.sc_url.clone()?,
failover: args.sc_failover_url.clone(),
vmac: args.sc_vmac?,
uuid: args.sc_device_uuid?,
})
}
#[cfg(feature = "sc-tls")]
fn sc_credentials(args: &TransportArgs) -> Option<Result<Credentials, String>> {
let lint = crate::sc_check::lint::Lint::run(
args.sc_cert.as_deref()?,
args.sc_ca.as_deref(),
args.sc_key.as_deref(),
None,
);
Some(lint.map(|lint| Credentials::from_lint(&lint)))
}
pub(crate) async fn finish_build<T: TransportPort + 'static>(
builder: Pin<Box<impl Future<Output = Result<BACnetClient<T>, Error>>>>,
sink: &EventSink,
) -> Option<BACnetClient<T>> {
match builder.await {
Ok(mut client) if sink.is_cancelled() => {
if let Err(error) = client.stop().await {
tracing::warn!("client stop: {error}");
}
None
}
Ok(client) => Some(client),
Err(error) => {
failed(sink, error).await;
None
}
}
}
async fn failed(sink: &EventSink, error: Error) {
tracing::error!("connect failed: {error}");
sink.deliver(WorkerEvent::ConnectFailed {
error: crate::tui::message::Failure::of(&error),
})
.await;
}
struct Resolving<'a> {
op: OpId,
spec: WhoIsSpec,
vmac: Pin<Box<dyn Future<Output = Result<Vec<u8>, String>> + Send + 'a>>,
}
async fn resolved(resolving: &mut Option<Resolving<'_>>) -> Result<Vec<u8>, String> {
match resolving {
Some(lookup) => lookup.vmac.as_mut().await,
None => std::future::pending().await,
}
}
pub(crate) async fn serve<T: TransportPort + 'static>(
client: BACnetClient<T>,
style: AddressStyle,
sink: EventSink,
commands: mpsc::Receiver<Command>,
options: SessionOptions,
) {
Box::pin(serve_with(client, style, sink, commands, options, None)).await;
}
async fn serve_with<T: TransportPort + 'static>(
client: BACnetClient<T>,
style: AddressStyle,
sink: EventSink,
mut commands: mpsc::Receiver<Command>,
options: SessionOptions,
sc_events: Option<broadcast::Receiver<ScEvent>>,
) {
let client = Arc::new(client);
let rate = RateCap::new(options.max_rate);
let mut local = style.format(client.local_mac());
if let Some(bound) =
crate::core::target::as_bip6(client.transport()).and_then(|t| t.local_address())
{
local = format!("{local} ({bound})");
}
tracing::info!("connected; local address {local}");
let mut devices = client.device_events();
let mut collisions = client.device_collision_events();
if sink
.deliver(WorkerEvent::Connected {
local: local.clone(),
})
.await
{
let (browse, browse_rx) = mpsc::channel(COMMAND_CHANNEL_CAPACITY);
let (watch, watch_rx) = mpsc::channel(WATCH_CHANNEL_CAPACITY);
let (safety, safety_rx) = mpsc::channel(COMMAND_CHANNEL_CAPACITY);
let (network, network_rx) = mpsc::channel(COMMAND_CHANNEL_CAPACITY);
let (discovery, discovery_rx) = mpsc::channel(COMMAND_CHANNEL_CAPACITY);
let (capture, capture_rx) = mpsc::channel(COMMAND_CHANNEL_CAPACITY);
let (sc_stop, sc_stop_rx) = mpsc::channel::<()>(1);
let quiet = options.safety.passive;
let session = async {
let browse = browse;
let watch = watch;
let safety = safety;
let network = network;
let discovery = discovery;
let capture = capture;
let _sc_stop = sc_stop;
let mut feed = DeviceFeed::new(style);
let mut listening: Option<(OpId, Instant)> = None;
let mut resolving: Option<Resolving<'_>> = None;
let mut resync = tokio::time::interval(RESYNC_EVERY);
resync.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
let deadline = listening.map_or_else(far_future, |(_, until)| until);
tokio::select! {
biased;
() = sink.cancelled() => break,
command = commands.recv() => match command {
Some(command) if quiet && passive::transmits(&command) => {
passive::refuse(&sink, command).await;
}
Some(Command::Safety(command)) => {
if safety.send(command).await.is_err() {
tracing::debug!("the write session has ended");
}
}
Some(Command::WhoIs { op, spec }) => {
let superseded = listening.take().map(|(old, _)| old);
let superseded = superseded.or(resolving.take().map(|lookup| lookup.op));
if let Some(old) = superseded {
sink.deliver(WorkerEvent::OpFinished { op: old, outcome: OpOutcome::Cancelled }).await;
}
if let WhoIsScope::Directed { to: DirectedTo::Bip6(peer), .. } = &spec.scope {
let vmac = Box::pin(crate::core::target::bip6_vmac(client.transport(), *peer));
resolving = Some(Resolving { op, spec, vmac });
} else {
listening = who_is(&client, &sink, op, &spec).await;
}
}
Some(Command::Cancel { op }) => {
let running = listening.is_some_and(|(id, _)| id == op)
|| resolving.as_ref().is_some_and(|lookup| lookup.op == op);
if running {
listening = None;
resolving = None;
tracing::info!("Who-Is {op} cancelled");
sink.deliver(WorkerEvent::OpFinished { op, outcome: OpOutcome::Cancelled }).await;
}
}
Some(command @ (Command::Browse { .. } | Command::Names { .. } | Command::StopBrowse { .. })) => {
if let Err(refused) = browse.try_send(command) {
let refused = refused.into_inner();
if let Command::StopBrowse { device } = refused {
tracing::debug!("browser busy; dropped the stop for device {device}");
}
if let Command::Browse { req, .. } | Command::Names { req, .. } = refused {
let result = BrowseResult::Failed("the browser is busy; try again".into());
sink.deliver(WorkerEvent::Browse { req, result }).await;
}
}
}
Some(command @ Command::Watch(_)) => {
if watch.send(command).await.is_err() {
tracing::debug!("the watch list has ended");
}
}
Some(Command::Network(command)) => {
if network.send(command).await.is_err() {
tracing::debug!("the network worker has ended");
}
}
Some(Command::Discovery(command)) => {
if discovery.send(command).await.is_err() {
tracing::debug!("the discovery worker has ended");
}
}
Some(Command::Capture(command)) => {
if capture.send(command).await.is_err() {
tracing::debug!("the capture has ended");
}
}
Some(Command::Disconnect) => break,
Some(Command::Connect { .. } | Command::JoinTrunk { .. } | Command::Reconnect | Command::Profile(_) | Command::CaptureExport(_)) => {}
None => break,
},
vmac = resolved(&mut resolving), if resolving.is_some() => {
if let Some(Resolving { op, spec, .. }) = resolving.take() {
listening = match vmac {
Ok(mac) => who_is(&client, &sink, op, &spec.with_vmac(mac)).await,
Err(error) => {
failed_who_is(&sink, op, error).await;
None
}
};
}
}
received = devices.recv(), if feed.devices_open => feed.on_device(&sink, received),
received = collisions.recv(), if feed.collisions_open => {
if let Some(event) = feed.on_collision(&sink, received) {
sink.deliver(event).await;
}
}
() = tokio::time::sleep_until(deadline), if listening.is_some() => {
if let Some((op, _)) = listening.take() {
sink.deliver(WorkerEvent::OpFinished { op, outcome: OpOutcome::Completed }).await;
}
}
_ = resync.tick(), if feed.needs_resync => {
let rows = client.discovered_devices().await.iter().map(|d| feed.row(d)).collect();
if sink.offer(WorkerEvent::Snapshot(rows)) == super::Offer::Sent {
feed.needs_resync = false;
}
}
}
}
while let Ok(command) = commands.try_recv() {
if let Command::Safety(command) = command {
let _ = safety.send(command).await;
}
}
};
tokio::join!(
Box::pin(session),
Box::pin(super::browse::run(&client, &sink, browse_rx, &rate)),
Box::pin(super::watch::run(
&client,
&sink,
watch_rx,
&rate,
&options.watch
)),
Box::pin(super::write::run(
&client,
&sink,
safety_rx,
&rate,
&options.safety,
&local
)),
Box::pin(super::network::run(&client, &sink, network_rx)),
Box::pin(super::discovery::run(&client, &sink, discovery_rx, style)),
Box::pin(super::sc::run(&client, &sink, sc_stop_rx, sc_events)),
Box::pin(crate::tui::capture::worker::run(
&*client, &sink, capture_rx, style
))
);
}
stop_shared(client).await;
}
const RELEASE_WAIT: (Duration, Duration) = (Duration::from_secs(1), Duration::from_secs(10));
async fn stop_shared<T: TransportPort + 'static>(mut client: Arc<BACnetClient<T>>) {
let start = Instant::now();
let mut warned = false;
loop {
match Arc::try_unwrap(client) {
Ok(mut owned) => {
if let Err(error) = owned.stop().await {
tracing::warn!("client stop: {error}");
}
return;
}
Err(shared) => client = shared,
}
let waited = start.elapsed();
if waited >= RELEASE_WAIT.0 + RELEASE_WAIT.1 {
tracing::error!(
"a COV renewal task still holds the client after {} s; dropping it unstopped",
waited.as_secs()
);
return;
}
if waited >= RELEASE_WAIT.0 && !warned {
warned = true;
tracing::warn!("waiting for a COV renewal task to release the client");
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
fn far_future() -> Instant {
Instant::now() + Duration::from_secs(86_400)
}
async fn who_is<T: TransportPort + 'static>(
client: &BACnetClient<T>,
sink: &EventSink,
op: OpId,
spec: &WhoIsSpec,
) -> Option<(OpId, Instant)> {
let result = tokio::select! {
biased;
() = sink.cancelled() => return None,
result = send_who_is(client, spec) => result,
};
match result {
Ok(()) => {
tracing::info!("Who-Is {op} sent ({})", spec.describe());
sink.deliver(WorkerEvent::WhoIsSent { op }).await;
Some((op, Instant::now() + spec.listen))
}
Err(error) => {
failed_who_is(sink, op, error).await;
None
}
}
}
async fn failed_who_is(sink: &EventSink, op: OpId, error: String) {
tracing::warn!("Who-Is {op} failed: {error}");
sink.deliver(WorkerEvent::OpFinished {
op,
outcome: OpOutcome::Failed(error),
})
.await;
}
async fn send_who_is<T: TransportPort + 'static>(
client: &BACnetClient<T>,
spec: &WhoIsSpec,
) -> Result<(), String> {
let range = spec.range;
let sent = match &spec.scope {
WhoIsScope::Local => {
let mut buf = BytesMut::new();
WhoIsRequest { range }.encode(&mut buf);
client
.broadcast_unconfirmed(UnconfirmedServiceChoice::WHO_IS, &buf)
.await
}
WhoIsScope::Global => client.who_is(range).await,
WhoIsScope::Directed {
to: DirectedTo::Mac(mac),
..
} => client.who_is_directed(mac, range).await,
WhoIsScope::Directed {
to: DirectedTo::Bip6(peer),
..
} => return Err(format!("the VMAC at {peer} has not been found")),
WhoIsScope::Network(dnet) => client.who_is_network(*dnet, range).await,
};
sent.map_err(|error| error.to_string())
}