Skip to main content

bacnet_ops/
target.rs

1//! The target grammar shared by the one-shot commands and the TUI,
2//! and the way each transport's MAC addresses are shown so they read back.
3//!
4//! Resolving a target to something a request can go to is in
5//! [`super::destination`].
6
7use std::any::Any;
8use std::net::{Ipv4Addr, SocketAddrV6};
9
10use bacnet_transport::bip6::{
11    Bip6Transport, DEFAULT_BACNET6_PORT, VMAC_TEXT_PREFIX, parse_vmac_text,
12};
13use bacnet_transport::port::TransportPort;
14use bacnet_types::error::Error;
15
16/// A parsed target for a BACnet command.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum Target {
19    /// A MAC on this network, as written.
20    Mac(Vec<u8>),
21    /// A B/IPv6 address. The node there is addressed by its VMAC, which
22    /// [`bip6_vmac`] finds.
23    Bip6(SocketAddrV6),
24    /// A device instance, looked up in the client's discovery table.
25    Instance(u32),
26    /// A device behind a router: its network and device instance.
27    Routed {
28        /// Remote network number.
29        dnet: u16,
30        /// Device instance.
31        instance: u32,
32    },
33    /// A station behind a router: its network and its MAC there.
34    RoutedMac {
35        /// Remote network number.
36        dnet: u16,
37        /// The station's MAC on that network.
38        mac: Vec<u8>,
39    },
40}
41
42/// Encode an IPv4 address and port as a 6-byte BIP MAC address.
43///
44/// The format is 4 bytes of IPv4 address followed by 2 bytes of port in
45/// big-endian byte order.
46pub fn ip_to_bip_mac(ip: Ipv4Addr, port: u16) -> Vec<u8> {
47    let mut mac = Vec::with_capacity(6);
48    mac.extend_from_slice(&ip.octets());
49    mac.extend_from_slice(&port.to_be_bytes());
50    mac
51}
52
53/// The forms [`parse_target`] takes, for error messages.
54pub const TARGET_FORMS: &str = "IP[:port], a 6-octet MAC or SC VMAC \
55     (02:00:00:00:00:01 or 020000000001), bip6:INSTANCE, bip6:0xVMAC, [IPv6][:port], \
56     mstp:STATION, device instance, dnet:instance or dnet:0xMAC";
57
58/// The prefix of an MS/TP station's address, `mstp:5` or `mstp:0x05`.
59pub const MSTP_TEXT_PREFIX: &str = "mstp:";
60
61/// An MS/TP station's MAC from the text after `mstp:`: 0 to 254, in decimal
62/// or as `0x` and hex. 255 is the broadcast address, not a station.
63fn parse_mstp_station(text: &str) -> Result<u8, String> {
64    // Digits only: the integer parsers would also take a leading `+`.
65    let value = match text.strip_prefix("0x").or(text.strip_prefix("0X")) {
66        Some(hex) if hex.bytes().all(|b| b.is_ascii_hexdigit()) => u8::from_str_radix(hex, 16).ok(),
67        None if text.bytes().all(|b| b.is_ascii_digit()) => text.parse::<u8>().ok(),
68        _ => None,
69    };
70    match value {
71        Some(station) if station != bacnet_transport::mstp::codec::BROADCAST_MAC => Ok(station),
72        _ => Err(format!(
73            "invalid MS/TP station '{MSTP_TEXT_PREFIX}{text}': expected 0 to 254, in decimal or 0xHH"
74        )),
75    }
76}
77
78/// Parse a target string into a [`Target`].
79///
80/// Supported formats:
81/// - `dnet:instance`: a routed device (e.g., `2:1234`)
82/// - `dnet:0xHEX`: a routed station by its MAC on that network (e.g.,
83///   `2:0x05` for MS/TP station 5)
84/// - `ip` or `ip:port`: a direct BIP MAC (e.g., `192.168.1.100` or
85///   `192.168.1.100:47808`)
86/// - six hex octets, `02:00:00:00:00:01`, `02-00-00-00-00-01` or
87///   `020000000001`: a 6-octet MAC as written, such as a BACnet/SC VMAC
88/// - `bip6:instance` or `bip6:0xHHHHHH`: a B/IPv6 VMAC, a device instance's
89///   (Clause H.7.2) or any three octets, as
90///   [`format_vmac_text`](bacnet_transport::bip6::format_vmac_text) prints them
91/// - `[ipv6]` or `[ipv6]:port`: a B/IPv6 address (port 47808 when absent),
92///   whose VMAC [`bip6_vmac`] finds
93/// - `mstp:station` or `mstp:0xHH`: an MS/TP station's 1-octet MAC, 0 to 254
94/// - `instance`: a device instance number (e.g., `1234`)
95pub fn parse_target(s: &str) -> Result<Target, String> {
96    if let Some(station) = s.strip_prefix(MSTP_TEXT_PREFIX) {
97        return parse_mstp_station(station).map(|mac| Target::Mac(vec![mac]));
98    }
99    if s.starts_with(VMAC_TEXT_PREFIX) {
100        return parse_vmac_text(s)
101            .map(|vmac| Target::Mac(vmac.octets().to_vec()))
102            .map_err(|error| match error {
103                Error::Encoding(message) | Error::OutOfRange(message) => message,
104                other => other.to_string(),
105            });
106    }
107    if s.starts_with('[') {
108        return parse_bip6_address(s).map(Target::Bip6);
109    }
110    if let Some(mac) = parse_six_octets(s) {
111        return Ok(Target::Mac(mac));
112    }
113
114    if let Some((left, right)) = s.split_once(':') {
115        if let Ok(dnet) = left.parse::<u16>() {
116            let routed =
117                right.parse::<u32>().is_ok() || right.starts_with("0x") || right.starts_with("0X");
118            // 0 isn't a network number, and 65535 means every network.
119            if routed && matches!(dnet, 0 | u16::MAX) {
120                return Err(format!(
121                    "invalid network {dnet} in routed target '{s}': expected 1 to 65534"
122                ));
123            }
124            // dnet:instance: both parts numeric.
125            if let Ok(instance) = right.parse::<u32>() {
126                return Ok(Target::Routed { dnet, instance });
127            }
128            if let Some(hex) = right.strip_prefix("0x").or(right.strip_prefix("0X")) {
129                let mac = parse_hex(hex).ok_or_else(|| {
130                    format!("invalid MAC in routed target '{s}': expected 0x and hex octets")
131                })?;
132                return Ok(Target::RoutedMac { dnet, mac });
133            }
134        }
135        // If the left side is not a network number, it might be an IP:port.
136        if let Ok(ip) = left.parse::<Ipv4Addr>() {
137            let port = right
138                .parse::<u16>()
139                .map_err(|_| format!("invalid port in target '{s}'"))?;
140            return Ok(Target::Mac(ip_to_bip_mac(ip, port)));
141        }
142    }
143
144    // Try parsing as bare IP address (no port).
145    if let Ok(ip) = s.parse::<Ipv4Addr>() {
146        return Ok(Target::Mac(ip_to_bip_mac(ip, 0xBAC0)));
147    }
148
149    // Try parsing as plain device instance number.
150    if let Ok(instance) = s.parse::<u32>() {
151        return Ok(Target::Instance(instance));
152    }
153
154    Err(format!("invalid target '{s}': expected {TARGET_FORMS}"))
155}
156
157/// Six hex octets: twelve digits, or six pairs split by `:` or `-`. Twelve
158/// characters can't be an IPv4 address or a device instance, so the forms
159/// never collide.
160fn parse_six_octets(s: &str) -> Option<Vec<u8>> {
161    if s.len() == 12 {
162        return parse_hex(s);
163    }
164    let separator = if s.contains(':') { ':' } else { '-' };
165    let parts: Vec<&str> = s.split(separator).collect();
166    if parts.len() != 6 || parts.iter().any(|part| part.len() != 2) {
167        return None;
168    }
169    parts
170        .iter()
171        .map(|part| u8::from_str_radix(part, 16).ok())
172        .collect()
173}
174
175/// An even, nonzero number of hex digits as octets.
176fn parse_hex(s: &str) -> Option<Vec<u8>> {
177    if s.is_empty() || !s.len().is_multiple_of(2) || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
178        return None;
179    }
180    (0..s.len())
181        .step_by(2)
182        .map(|at| u8::from_str_radix(&s[at..at + 2], 16).ok())
183        .collect()
184}
185
186/// `[ipv6]` or `[ipv6]:port`, port 47808 when absent. A link-local address
187/// may carry its interface as a numeric zone, `[fe80::1%3]`.
188fn parse_bip6_address(s: &str) -> Result<SocketAddrV6, String> {
189    let with_port;
190    let text = if s.ends_with(']') {
191        with_port = format!("{s}:{DEFAULT_BACNET6_PORT}");
192        &with_port
193    } else {
194        s
195    };
196    text.parse()
197        .map_err(|_| format!("invalid B/IPv6 address '{s}': expected [IPv6] or [IPv6]:port"))
198}
199
200/// `transport` as the B/IPv6 transport, when it is one. The commands are
201/// generic over the transport; only B/IPv6 maps addresses to VMACs.
202pub fn as_bip6<T: TransportPort + 'static>(transport: &T) -> Option<&Bip6Transport> {
203    (transport as &dyn Any).downcast_ref()
204}
205
206/// True when `transport` is the BACnet/SC transport the CLI builds.
207fn is_sc<T: TransportPort + 'static>(transport: &T) -> bool {
208    #[cfg(feature = "sc-tls")]
209    {
210        (transport as &dyn Any)
211            .is::<bacnet_transport::sc::ScTransport<bacnet_transport::sc_tls::TlsWebSocket>>()
212    }
213    #[cfg(not(feature = "sc-tls"))]
214    {
215        let _ = transport;
216        false
217    }
218}
219
220/// True when `transport` is MS/TP: the only data link the CLI uses whose
221/// MACs are one octet.
222pub fn is_mstp<T: TransportPort + 'static>(transport: &T) -> bool {
223    transport.local_mac().len() == 1
224}
225
226/// [`parse_target`] for a command on `transport`. A `bip6:` VMAC names a
227/// B/IPv6 node and `mstp:` an MS/TP station, so any other transport refuses
228/// them here, rather than hand its link a MAC it can't send to; B/IPv6
229/// refuses a 6-octet MAC, and MS/TP any MAC but a 1-octet one.
230pub fn parse_target_on<T: TransportPort + 'static>(
231    transport: &T,
232    s: &str,
233) -> Result<Target, String> {
234    let bip6 = as_bip6(transport).is_some();
235    let mstp = is_mstp(transport);
236    if s.starts_with(VMAC_TEXT_PREFIX) && !bip6 {
237        return Err(format!(
238            "'{s}' is a B/IPv6 VMAC: bip6: addresses need the --ipv6 transport"
239        ));
240    }
241    if s.starts_with(MSTP_TEXT_PREFIX) && !mstp {
242        return Err(format!(
243            "'{s}' is an MS/TP station: mstp: addresses need the --mstp transport"
244        ));
245    }
246    let target = parse_target(s)?;
247    if let Target::Mac(mac) = &target {
248        if bip6 && mac.len() == 6 {
249            return Err(format!(
250                "'{s}' is a 6-octet MAC; a B/IPv6 node is addressed by its 3-octet VMAC (bip6:...)"
251            ));
252        }
253        if mstp && mac.len() != 1 {
254            return Err(format!(
255                "'{s}' is a {}-octet MAC; an MS/TP station is addressed by mstp:N",
256                mac.len()
257            ));
258        }
259    }
260    Ok(target)
261}
262
263/// Find the VMAC of the B/IPv6 node at `peer`, the MAC a command sends to.
264///
265/// A B/IPv6 node is addressed by its VMAC, not its IPv6 address (Clause
266/// U.1.3), so the CLI asks the node once, with a Virtual-Address-Resolution
267/// (Clause U.5), unless the transport already knows which VMAC is there.
268/// Fails on any transport but B/IPv6, and when nothing answers within three
269/// seconds.
270pub async fn bip6_vmac<T: TransportPort + 'static>(
271    transport: &T,
272    peer: SocketAddrV6,
273) -> Result<Vec<u8>, String> {
274    let bip6 = as_bip6(transport)
275        .ok_or_else(|| format!("{peer} is a B/IPv6 address: use it with --ipv6"))?;
276    let vmac = bip6
277        .resolve_virtual_address(peer)
278        .await
279        .map_err(|e| format!("could not find the VMAC at {peer}: {e}"))?;
280    Ok(vmac.octets().to_vec())
281}
282
283/// Why a directed Who-Is refuses a device instance or routed target.
284pub const TARGET_NOT_AN_ADDRESS: &str =
285    "--target requires an address, not a device instance or routed address";
286
287/// The MAC of a target that must be an address, such as a directed
288/// Who-Is's: a MAC as written, or the VMAC [`bip6_vmac`] finds for a
289/// B/IPv6 address. A device instance or routed target fails with `refusal`.
290pub async fn address_mac<T: TransportPort + 'static>(
291    transport: &T,
292    text: &str,
293    refusal: &str,
294) -> Result<Vec<u8>, String> {
295    match parse_target_on(transport, text)? {
296        Target::Mac(mac) => Ok(mac),
297        Target::Bip6(peer) => bip6_vmac(transport, peer).await,
298        Target::Instance(_) | Target::Routed { .. } | Target::RoutedMac { .. } => {
299            Err(refusal.to_string())
300        }
301    }
302}
303
304/// How a transport's MAC addresses are shown.
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub enum AddressStyle {
307    /// 4-byte IPv4 address and 2-byte port.
308    Bip,
309    /// 3-byte B/IPv6 VMAC, as `bip6:0x0004D2` (Clause U.1.3).
310    Bip6,
311    /// Hex octets (BACnet/SC VMACs and anything else).
312    Hex,
313    /// A 1-byte MS/TP station, as `mstp:5`.
314    Mstp,
315}
316
317impl AddressStyle {
318    /// The style of `transport`'s addresses.
319    pub fn of<T: TransportPort + 'static>(transport: &T) -> Self {
320        if as_bip6(transport).is_some() {
321            Self::Bip6
322        } else if is_mstp(transport) {
323            Self::Mstp
324        } else if is_sc(transport) {
325            Self::Hex
326        } else {
327            Self::Bip
328        }
329    }
330
331    /// Format a MAC for display, in a form [`parse_target`] reads back.
332    pub fn format(self, mac: &[u8]) -> String {
333        match (self, mac) {
334            (Self::Mstp, [station]) => format!("{MSTP_TEXT_PREFIX}{station}"),
335            (Self::Bip, [_, _, _, _, _, _]) | (Self::Bip6, [_, _, _]) => format_mac(mac),
336            _ => hex(mac),
337        }
338    }
339
340    /// Where a device or a reply is, as the TUI's tables show it. For one
341    /// a router passed on, from `routed` (its network and its MAC there):
342    /// the routed station as [`routed_station`] writes it, and the router,
343    /// the link peer `mac`, in this style. Otherwise, or when the routed
344    /// MAC is empty and so names no station, `mac` in this style and no
345    /// router.
346    pub fn locate(self, mac: &[u8], routed: Option<(u16, &[u8])>) -> (String, Option<String>) {
347        match routed.and_then(|(network, station)| routed_station(network, station)) {
348            Some(station) => (station, Some(self.format(mac))),
349            None => (self.format(mac), None),
350        }
351    }
352}
353
354/// A routed station for display, `NET:0xMAC` with the MAC in upper-case
355/// hex (`2:0x7F000001C335`, an MS/TP station `2:0x05`): a station is
356/// written in the target syntax, so [`parse_target`] reads it back. An
357/// empty MAC names no station (it is a broadcast on that network), so it
358/// has no target form and gives `None`; the capture, which shows routed
359/// broadcasts, writes those itself.
360pub fn routed_station(network: u16, mac: &[u8]) -> Option<String> {
361    if mac.is_empty() {
362        return None;
363    }
364    let mut out = format!("{network}:0x");
365    for b in mac {
366        out.push_str(&format!("{b:02X}"));
367    }
368    Some(out)
369}
370
371/// Format a MAC address as a target the CLI reads back: a BIP MAC (6 bytes:
372/// 4 IP + 2 port) as `ip:port`, and a B/IPv6 VMAC (3 bytes, the only
373/// 3-byte MAC the CLI's transports use) as `bip6:0x0004D2`. Falls back to
374/// hex display for other MACs. A routed station is written with its network
375/// by [`routed_station`] instead.
376pub fn format_mac(mac: &[u8]) -> String {
377    if mac.len() == 6 {
378        let ip = Ipv4Addr::new(mac[0], mac[1], mac[2], mac[3]);
379        let port = u16::from_be_bytes([mac[4], mac[5]]);
380        format!("{ip}:{port}")
381    } else if let Ok(vmac) = <[u8; 3]>::try_from(mac) {
382        bacnet_transport::bip6::format_vmac_text(bacnet_transport::bip6::Bip6Vmac::new(vmac))
383    } else {
384        mac.iter()
385            .map(|b| format!("{b:02x}"))
386            .collect::<Vec<_>>()
387            .join(":")
388    }
389}
390
391/// Colon-separated lowercase hex.
392pub fn hex(bytes: &[u8]) -> String {
393    let mut out = String::with_capacity(bytes.len() * 3);
394    for (i, b) in bytes.iter().enumerate() {
395        if i > 0 {
396            out.push(':');
397        }
398        out.push_str(&format!("{b:02x}"));
399    }
400    out
401}
402
403#[cfg(test)]
404#[path = "target_tests.rs"]
405mod tests;