Skip to main content

bacnet_server/pics/
mod.rs

1//! Protocol Implementation Conformance Statement (PICS) generation per ASHRAE 135-2020 Annex A.
2//!
3//! A PICS document is a formal declaration of which BACnet features a device supports.
4//! It is required for BACnet certification and interoperability testing.
5
6use std::collections::BTreeMap;
7use std::fmt;
8
9use bacnet_encoding::primitives::charset;
10use bacnet_objects::database::ObjectDatabase;
11use bacnet_objects::traits::BACnetObject;
12use bacnet_types::bitstring::ServicesSupported;
13use bacnet_types::enums::{ObjectType, PropertyIdentifier, ServiceSupported};
14use bacnet_types::primitives::PropertyValue;
15use bacnet_types::{PROTOCOL_REVISION, PROTOCOL_VERSION};
16
17use crate::local_device::announcement_device;
18use crate::server::ServerConfig;
19
20// ───────────────────────────── Data model ──────────────────────────────────
21
22/// Complete PICS document per ASHRAE 135-2020 Annex A.
23#[derive(Debug, Clone)]
24pub struct Pics {
25    /// Vendor and device identification.
26    pub vendor_info: VendorInfo,
27    /// Device profile claimed.
28    pub device_profile: DeviceProfile,
29    /// Object types present in the database with their creation, deletion and property support.
30    pub supported_object_types: Vec<ObjectTypeSupport>,
31    /// Services the server can initiate or execute.
32    pub supported_services: Vec<ServiceSupport>,
33    /// Data-link layers the device supports.
34    pub data_link_layers: Vec<DataLinkSupport>,
35    /// Network-layer roles the device fills.
36    pub network_layer: NetworkLayerSupport,
37    /// Character sets the device supports.
38    pub character_sets: Vec<CharacterSet>,
39    /// Free-text descriptions of special functionality.
40    pub special_functionality: Vec<String>,
41}
42
43/// Vendor and device identification.
44#[derive(Debug, Clone)]
45pub struct VendorInfo {
46    /// Vendor identifier assigned by ASHRAE: the Device object's
47    /// Vendor_Identifier (see [`PicsGenerator::generate`]).
48    pub vendor_id: u16,
49    /// The Device object's Vendor_Name, as read (see [`PicsGenerator::generate`]).
50    pub vendor_name: String,
51    /// The Device object's Model_Name, as read.
52    pub model_name: String,
53    /// The Device object's Firmware_Revision, as read.
54    pub firmware_revision: String,
55    /// The Device object's Application_Software_Version, as read.
56    pub application_software_version: String,
57    /// BACnet protocol version implemented: the Device object's
58    /// Protocol_Version (see [`PicsGenerator::generate`]).
59    pub protocol_version: u16,
60    /// BACnet protocol revision implemented: the Device object's
61    /// Protocol_Revision (see [`PicsGenerator::generate`]).
62    pub protocol_revision: u16,
63}
64
65/// BACnet device profile.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum DeviceProfile {
68    /// BACnet Advanced Application Controller.
69    BAac,
70    /// BACnet Application Specific Controller.
71    BAsc,
72    /// BACnet Operator Workstation.
73    BOws,
74    /// BACnet Building Controller.
75    BBc,
76    /// BACnet Operator Panel.
77    BOp,
78    /// BACnet Router.
79    BRouter,
80    /// BACnet Gateway.
81    BGw,
82    /// BACnet Smart Controller.
83    BSc,
84    /// Custom / non-standard profile.
85    Custom(String),
86}
87
88impl fmt::Display for DeviceProfile {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        match self {
91            Self::BAac => f.write_str("B-AAC"),
92            Self::BAsc => f.write_str("B-ASC"),
93            Self::BOws => f.write_str("B-OWS"),
94            Self::BBc => f.write_str("B-BC"),
95            Self::BOp => f.write_str("B-OP"),
96            Self::BRouter => f.write_str("B-ROUTER"),
97            Self::BGw => f.write_str("B-GW"),
98            Self::BSc => f.write_str("B-SC"),
99            Self::Custom(s) => f.write_str(s),
100        }
101    }
102}
103
104/// Property capabilities across configured instances of an object type.
105/// Read/write flags mean at least one instance supports that access. Optional is
106/// true only when every present metadata row is optional; absent rows do not vote.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub struct PropertyAccess {
109    /// At least one instance can be read.
110    pub readable: bool,
111    /// At least one instance can be written.
112    pub writable: bool,
113    /// Every instance that has the property treats it as optional.
114    pub optional: bool,
115}
116
117impl fmt::Display for PropertyAccess {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        let r = if self.readable { "R" } else { "" };
120        let w = if self.writable { "W" } else { "" };
121        let o = if self.optional { "O" } else { "" };
122        write!(f, "{r}{w}{o}")
123    }
124}
125
126/// Supported property with its access flags.
127#[derive(Debug, Clone)]
128pub struct PropertySupport {
129    /// Property this row describes.
130    pub property_id: PropertyIdentifier,
131    /// Aggregated access flags for the property.
132    pub access: PropertyAccess,
133    /// The property whose writes change this one on some instance, although
134    /// a write naming this one is refused: State_Text, written whole or at
135    /// index 0, for a multi-state object's Number_Of_States (#1443).
136    pub written_through: Option<PropertyIdentifier>,
137}
138
139/// Object type support declaration.
140#[derive(Debug, Clone)]
141pub struct ObjectTypeSupport {
142    /// Object type described.
143    pub object_type: ObjectType,
144    /// Whether the type can be created remotely with CreateObject.
145    pub createable: bool,
146    /// The properties a CreateObject initial value may set whole on a type
147    /// that is createable, although WriteProperty can't change them later
148    /// (#1429). Empty when the type isn't createable. A property writes of
149    /// another one change, such as Number_Of_States, isn't listed: its row
150    /// says so instead ([`PropertySupport::written_through`]).
151    pub creation_only_properties: Vec<PropertyIdentifier>,
152    /// Whether the type can be deleted remotely with DeleteObject.
153    pub deleteable: bool,
154    /// Union of effective instance rows in ascending property-ID order.
155    pub supported_properties: Vec<PropertySupport>,
156}
157
158/// Service support declaration.
159#[derive(Debug, Clone)]
160pub struct ServiceSupport {
161    /// Service name.
162    pub service_name: String,
163    /// Device can request the service.
164    pub initiator: bool,
165    /// Device can execute the service.
166    pub executor: bool,
167}
168
169/// Data link layer support.
170///
171/// It is `#[non_exhaustive]`, so a new option isn't a breaking change: a
172/// `match` on it needs a wildcard arm.
173#[derive(Debug, Clone, PartialEq, Eq)]
174#[non_exhaustive]
175pub enum DataLinkSupport {
176    /// BACnet/IP over IPv4.
177    BipV4,
178    /// BACnet/IP over IPv4 with NAT traversal: a BBMD behind a NAT router,
179    /// reached at a configured global address (Annex J.7.5). Claim it with
180    /// [`BipV4`](Self::BipV4) for a device that can run that way.
181    BipV4NatTraversal,
182    /// BACnet/IP over IPv6.
183    BipV6,
184    /// MS/TP over RS-485.
185    Mstp,
186    /// Raw BACnet Ethernet (802.3).
187    Ethernet,
188    /// BACnet Secure Connect.
189    BacnetSc,
190}
191
192impl fmt::Display for DataLinkSupport {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        match self {
195            Self::BipV4 => f.write_str("BACnet/IP (Annex J)"),
196            Self::BipV4NatTraversal => f.write_str("BACnet/IP with NAT traversal (Annex J.7.5)"),
197            Self::BipV6 => f.write_str("BACnet/IPv6 (Annex U)"),
198            Self::Mstp => f.write_str("MS/TP (Clause 9)"),
199            Self::Ethernet => f.write_str("BACnet Ethernet (Clause 7)"),
200            Self::BacnetSc => f.write_str("BACnet/SC (Annex AB)"),
201        }
202    }
203}
204
205/// Network layer capabilities.
206#[derive(Debug, Clone)]
207pub struct NetworkLayerSupport {
208    /// Device routes between networks.
209    pub router: bool,
210    /// Device acts as a BACnet Broadcast Management Device.
211    pub bbmd: bool,
212    /// Device can register as a foreign device with a BBMD.
213    pub foreign_device: bool,
214}
215
216/// A character set the PICS can claim under Annex A "Character Sets Supported".
217///
218/// The variants are exactly the six sets Annex A offers. Each discriminant is the
219/// set's code from Clause 20.2.9, the initial octet of an encoded CharacterString.
220/// `Display` prints the set's Annex A label. Claiming several sets does not mean
221/// the device can use them all at once.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
223#[repr(u8)]
224pub enum CharacterSet {
225    /// ISO 10646 UTF-8, code X'00'.
226    Utf8 = charset::UTF8,
227    /// IBM/Microsoft DBCS, code X'01'. Its strings carry a two-octet code page.
228    IbmMicrosoftDbcs = charset::IBM_MICROSOFT_DBCS,
229    /// JIS X 0208, code X'02'.
230    JisX0208 = charset::JIS_X_0208,
231    /// ISO 10646 UCS-4, code X'03'.
232    Ucs4 = charset::UCS4,
233    /// ISO 10646 UCS-2, code X'04'.
234    Ucs2 = charset::UCS2,
235    /// ISO 8859-1, code X'05'.
236    Iso8859_1 = charset::ISO_8859_1,
237}
238
239impl CharacterSet {
240    /// Every set Annex A offers, in Clause 20.2.9 code order.
241    pub const ALL: [Self; 6] = [
242        Self::Utf8,
243        Self::IbmMicrosoftDbcs,
244        Self::JisX0208,
245        Self::Ucs4,
246        Self::Ucs2,
247        Self::Iso8859_1,
248    ];
249
250    /// The set's Clause 20.2.9 code, one of the `bacnet_encoding::primitives::charset` constants.
251    pub const fn code(self) -> u8 {
252        self as u8
253    }
254}
255
256impl fmt::Display for CharacterSet {
257    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258        f.write_str(match self {
259            Self::Utf8 => "ISO 10646 (UTF-8)",
260            Self::IbmMicrosoftDbcs => "IBM/Microsoft DBCS",
261            Self::JisX0208 => "JIS X 0208",
262            Self::Ucs4 => "ISO 10646 (UCS-4)",
263            Self::Ucs2 => "ISO 10646 (UCS-2)",
264            Self::Iso8859_1 => "ISO 8859-1",
265        })
266    }
267}
268
269// ────────────────────────────── Configuration ──────────────────────────────
270
271/// Configuration for PICS generation that cannot be inferred from the database.
272#[derive(Debug, Clone)]
273pub struct PicsConfig {
274    /// Device profile claimed.
275    pub device_profile: DeviceProfile,
276    /// Data-link layers the device supports.
277    pub data_link_layers: Vec<DataLinkSupport>,
278    /// Network-layer roles the device fills.
279    pub network_layer: NetworkLayerSupport,
280    /// Character sets the device supports.
281    pub character_sets: Vec<CharacterSet>,
282    /// Free-text descriptions of special functionality.
283    pub special_functionality: Vec<String>,
284}
285
286impl Default for PicsConfig {
287    fn default() -> Self {
288        Self {
289            device_profile: DeviceProfile::BAsc,
290            data_link_layers: vec![DataLinkSupport::BipV4],
291            network_layer: NetworkLayerSupport {
292                router: false,
293                bbmd: false,
294                foreign_device: false,
295            },
296            character_sets: vec![CharacterSet::Utf8],
297            special_functionality: Vec::new(),
298        }
299    }
300}
301
302// ────────────────────────────── Generator ──────────────────────────────────
303
304/// Generates PICS from raw object declarations and configuration. This standalone
305/// generator does not establish service execution; use `BACnetServer::generate_pics`
306/// for the full server's effective Device contract.
307pub struct PicsGenerator<'a> {
308    db: &'a ObjectDatabase,
309    server_config: &'a ServerConfig,
310    pics_config: &'a PicsConfig,
311    served: bool,
312}
313
314impl<'a> PicsGenerator<'a> {
315    /// Create a generator over the object database and the server and PICS configuration.
316    pub fn new(
317        db: &'a ObjectDatabase,
318        server_config: &'a ServerConfig,
319        pics_config: &'a PicsConfig,
320    ) -> Self {
321        Self {
322            db,
323            server_config,
324            pics_config,
325            served: false,
326        }
327    }
328
329    pub(crate) fn for_server(mut self) -> Self {
330        self.served = true;
331        self
332    }
333
334    /// Generate the complete PICS document.
335    ///
336    /// The vendor block comes from the Device object, so the PICS states what
337    /// the device reports: for the server, the Device at its identity;
338    /// otherwise, or when the database lacks that one, the selected Device.
339    /// Vendor_Name, Model_Name, Firmware_Revision and
340    /// Application_Software_Version are its values at the time of the call,
341    /// or empty without a Device or when one isn't a character string. The
342    /// protocol version and revision are its Protocol_Version and
343    /// Protocol_Revision; without a Device, or when either property isn't an
344    /// unsigned value that fits in a `u16`, the stack's own
345    /// [`bacnet_types::PROTOCOL_VERSION`] and
346    /// [`bacnet_types::PROTOCOL_REVISION`] stand in. The vendor ID is the
347    /// same Device's Vendor_Identifier, which for the server's identity
348    /// Device is the [`ServerConfig`] identity's vendor ID; without a Device,
349    /// or when the value isn't an unsigned that fits in a `u16`, that
350    /// identity's vendor ID stands in.
351    pub fn generate(&self) -> Pics {
352        Pics {
353            vendor_info: self.build_vendor_info(),
354            device_profile: self.pics_config.device_profile.clone(),
355            supported_object_types: self.build_object_types(),
356            supported_services: self.build_services(),
357            data_link_layers: self.pics_config.data_link_layers.clone(),
358            network_layer: self.pics_config.network_layer.clone(),
359            character_sets: self.pics_config.character_sets.clone(),
360            special_functionality: self.pics_config.special_functionality.clone(),
361        }
362    }
363
364    fn build_vendor_info(&self) -> VendorInfo {
365        // The server reads the Device at its identity, as wildcard reads and
366        // announcements do; otherwise the selected (lowest) one. Reads go
367        // through the server's view, which for the identity Device serves
368        // the identity's Vendor_Identifier; the standalone view has no
369        // identity and passes every property read here through raw.
370        let device = (self.served)
371            .then(|| announcement_device(self.db, &self.server_config.device))
372            .flatten()
373            .or_else(|| self.db.selected_device())
374            .and_then(|oid| self.db.get(&oid));
375        let view = crate::device_view::DeviceReadContext::new(
376            self.db,
377            crate::device_view::DeviceExecution::FullServer,
378        )
379        .with_identity(self.served.then_some(&self.server_config.device));
380        let read = |property| device.map(|d| view.object(d).read_property(property, None));
381        let unsigned = |property, fallback| match read(property) {
382            Some(Ok(PropertyValue::Unsigned(raw))) => u16::try_from(raw).unwrap_or(fallback),
383            _ => fallback,
384        };
385        let text = |property| match read(property) {
386            Some(Ok(PropertyValue::CharacterString(value))) => value,
387            _ => String::new(),
388        };
389        VendorInfo {
390            vendor_id: unsigned(
391                PropertyIdentifier::VENDOR_IDENTIFIER,
392                self.server_config.device.vendor_id(),
393            ),
394            vendor_name: text(PropertyIdentifier::VENDOR_NAME),
395            model_name: text(PropertyIdentifier::MODEL_NAME),
396            firmware_revision: text(PropertyIdentifier::FIRMWARE_REVISION),
397            application_software_version: text(PropertyIdentifier::APPLICATION_SOFTWARE_VERSION),
398            protocol_version: unsigned(PropertyIdentifier::PROTOCOL_VERSION, PROTOCOL_VERSION),
399            protocol_revision: unsigned(PropertyIdentifier::PROTOCOL_REVISION, PROTOCOL_REVISION),
400        }
401    }
402
403    fn build_object_types(&self) -> Vec<ObjectTypeSupport> {
404        let mut by_type: BTreeMap<u32, Vec<&dyn bacnet_objects::traits::BACnetObject>> =
405            BTreeMap::new();
406        for (_oid, obj) in self.db.iter_objects() {
407            by_type
408                .entry(obj.object_identifier().object_type().to_raw())
409                .or_default()
410                .push(obj);
411        }
412
413        let mut result = Vec::with_capacity(by_type.len());
414        for (raw_type, objects) in &by_type {
415            let object_type = ObjectType::from_raw(*raw_type);
416            let representative = objects[0];
417            let view = (self.served && object_type == ObjectType::DEVICE).then(|| {
418                crate::device_view::DeviceReadContext::new(
419                    self.db,
420                    crate::device_view::DeviceExecution::FullServer,
421                )
422                .with_identity(Some(&self.server_config.device))
423            });
424            let supported_properties =
425                Self::union_property_support(objects.iter().flat_map(|object| {
426                    if let Some(view) = &view {
427                        Self::object_property_support(&view.object(*object))
428                    } else {
429                        Self::object_property_support(*object)
430                    }
431                }));
432
433            // Factory/deletion capabilities are type-level declarations, not
434            // per-instance property capabilities. Preserve that separate policy.
435
436            let createable = representative.is_createable();
437            let deleteable = representative.is_deleteable();
438            let written_through = |property: &PropertyIdentifier| {
439                supported_properties
440                    .iter()
441                    .any(|row| row.property_id == *property && row.written_through.is_some())
442            };
443            let creation_only_properties = if createable {
444                let mut properties = representative.creation_only_properties().to_vec();
445                properties.retain(|property| !written_through(property));
446                properties
447            } else {
448                Vec::new()
449            };
450
451            result.push(ObjectTypeSupport {
452                object_type,
453                createable,
454                creation_only_properties,
455                deleteable,
456                supported_properties,
457            });
458        }
459        result
460    }
461
462    fn object_property_support(
463        object: &dyn bacnet_objects::traits::BACnetObject,
464    ) -> Vec<PropertySupport> {
465        let metadata = object.property_metadata();
466        if metadata.is_empty() {
467            let all_props = object.property_list();
468            let required = object.required_properties();
469            all_props
470                .iter()
471                .map(|&property_id| PropertySupport {
472                    property_id,
473                    access: PropertyAccess {
474                        readable: true,
475                        writable: object.is_writable_property(property_id),
476                        optional: !required.contains(&property_id),
477                    },
478                    written_through: None,
479                })
480                .collect()
481        } else {
482            metadata
483                .iter()
484                .map(|row| PropertySupport {
485                    property_id: row.property_identifier,
486                    access: PropertyAccess {
487                        readable: true,
488                        writable: row.write_capability.is_writable(),
489                        optional: !row.is_required(),
490                    },
491                    written_through: row.write_capability.written_through(),
492                })
493                .collect()
494        }
495    }
496
497    fn union_property_support(
498        rows: impl IntoIterator<Item = PropertySupport>,
499    ) -> Vec<PropertySupport> {
500        // A type row records capabilities across all configured instances.
501        // Missing properties do not vote on conformance; a required present row
502        // wins over optional rows. Sort even a single instance by property ID.
503        let mut properties: BTreeMap<u32, PropertySupport> = BTreeMap::new();
504        for row in rows {
505            properties
506                .entry(row.property_id.to_raw())
507                .and_modify(|existing| {
508                    existing.access.readable |= row.access.readable;
509                    existing.access.writable |= row.access.writable;
510                    existing.access.optional &= row.access.optional;
511                    existing.written_through = existing.written_through.or(row.written_through);
512                })
513                .or_insert(row);
514        }
515        properties.into_values().collect()
516    }
517
518    /// The services the PICS marks as initiated (its initiator column): the
519    /// requests `bacnet-server` can build and send itself, in some
520    /// configuration. Distinct from
521    /// [`EXECUTED_SERVICES`](bacnet_objects::device::EXECUTED_SERVICES), which
522    /// Clause 12.11 ties to execution.
523    ///
524    /// Some go out only when configured: COV and event notifications need a
525    /// subscription or a recipient, audit notifications need audit reporting,
526    /// and WriteProperty, ReadProperty and Who-Is need a Command action or a
527    /// Channel member in another device (Who-Is finds that device). The server
528    /// sends them through its own outbound paths, not through `bacnet-client`.
529    /// A test scans the server's source and fails on an outbound service this
530    /// list leaves out.
531    pub const INITIATED_SERVICES: &'static [ServiceSupported] = &[
532        ServiceSupported::I_AM,
533        ServiceSupported::I_HAVE,
534        ServiceSupported::CONFIRMED_COV_NOTIFICATION,
535        ServiceSupported::CONFIRMED_EVENT_NOTIFICATION,
536        ServiceSupported::UNCONFIRMED_COV_NOTIFICATION,
537        ServiceSupported::UNCONFIRMED_EVENT_NOTIFICATION,
538        ServiceSupported::CONFIRMED_COV_NOTIFICATION_MULTIPLE,
539        ServiceSupported::UNCONFIRMED_COV_NOTIFICATION_MULTIPLE,
540        ServiceSupported::CONFIRMED_AUDIT_NOTIFICATION,
541        ServiceSupported::UNCONFIRMED_AUDIT_NOTIFICATION,
542        ServiceSupported::READ_PROPERTY,
543        ServiceSupported::WRITE_PROPERTY,
544        ServiceSupported::WHO_IS,
545    ];
546
547    fn build_services(&self) -> Vec<ServiceSupport> {
548        // Standalone callers inspect the selected Device's raw declaration. The
549        // full server always uses its fixed execution profile, filtered by
550        // database clock availability; a mutable/custom Device cannot override
551        // that contract.
552        let effective_executed = (!self.served)
553            .then(|| {
554                let device = self.db.get(&self.db.selected_device()?)?;
555                match device
556                    .read_property(PropertyIdentifier::PROTOCOL_SERVICES_SUPPORTED, None)
557                    .ok()?
558                {
559                    PropertyValue::BitString { data, .. } => {
560                        Some(ServicesSupported::from_bacnet(&data))
561                    }
562                    _ => None,
563                }
564            })
565            .flatten();
566        let mut service_map: BTreeMap<&'static str, (bool, bool)> = BTreeMap::new();
567        let clock_available = self.db.clock_frame().is_some();
568        let executed: Box<dyn Iterator<Item = ServiceSupported> + '_> = match &effective_executed {
569            Some(services) => Box::new(services.iter()),
570            None => {
571                Box::new(crate::device_view::DeviceExecution::FullServer.services(clock_available))
572            }
573        };
574        for service in executed {
575            service_map
576                .entry(service_display_name(service).unwrap_or("Unknown"))
577                .or_default()
578                .1 = true;
579        }
580        for service in Self::INITIATED_SERVICES {
581            service_map
582                .entry(service_display_name(*service).unwrap_or("Unknown"))
583                .or_default()
584                .0 = true;
585        }
586
587        service_map
588            .into_iter()
589            .map(|(name, (initiator, executor))| ServiceSupport {
590                service_name: name.to_string(),
591                initiator,
592                executor,
593            })
594            .collect()
595    }
596}
597
598// ─────────────────────────── Standalone helper ─────────────────────────────
599
600/// Generate a PICS document from an ObjectDatabase and configuration.
601///
602/// This is a convenience function for use without a running BACnetServer.
603pub fn generate_pics(
604    db: &ObjectDatabase,
605    server_config: &ServerConfig,
606    pics_config: &PicsConfig,
607) -> Pics {
608    PicsGenerator::new(db, server_config, pics_config).generate()
609}
610
611/// The name the PICS prints for a `BACnetServicesSupported` bit position,
612/// such as `ReadProperty` or `I-Am`, or `None` for a reserved bit. The PICS
613/// prints `Unknown` for a reserved bit a Device declares.
614pub fn service_display_name(service: ServiceSupported) -> Option<&'static str> {
615    Some(match service.to_raw() {
616        0 => "AcknowledgeAlarm",
617        1 => "ConfirmedCOVNotification",
618        2 => "ConfirmedEventNotification",
619        3 => "GetAlarmSummary",
620        4 => "GetEnrollmentSummary",
621        5 => "SubscribeCOV",
622        6 => "AtomicReadFile",
623        7 => "AtomicWriteFile",
624        8 => "AddListElement",
625        9 => "RemoveListElement",
626        10 => "CreateObject",
627        11 => "DeleteObject",
628        12 => "ReadProperty",
629        14 => "ReadPropertyMultiple",
630        15 => "WriteProperty",
631        16 => "WritePropertyMultiple",
632        17 => "DeviceCommunicationControl",
633        18 => "ConfirmedPrivateTransfer",
634        19 => "ConfirmedTextMessage",
635        20 => "ReinitializeDevice",
636        21 => "VT-Open",
637        22 => "VT-Close",
638        23 => "VT-Data",
639        26 => "I-Am",
640        27 => "I-Have",
641        28 => "UnconfirmedCOVNotification",
642        29 => "UnconfirmedEventNotification",
643        30 => "UnconfirmedPrivateTransfer",
644        31 => "UnconfirmedTextMessage",
645        32 => "TimeSynchronization",
646        33 => "WhoHas",
647        34 => "WhoIs",
648        35 => "ReadRange",
649        36 => "UTCTimeSynchronization",
650        37 => "LifeSafetyOperation",
651        38 => "SubscribeCOVProperty",
652        39 => "GetEventInformation",
653        40 => "WriteGroup",
654        41 => "SubscribeCOVPropertyMultiple",
655        42 => "ConfirmedCOVNotificationMultiple",
656        43 => "UnconfirmedCOVNotificationMultiple",
657        44 => "ConfirmedAuditNotification",
658        45 => "AuditLogQuery",
659        46 => "UnconfirmedAuditNotification",
660        47 => "Who-Am-I",
661        48 => "You-Are",
662        _ => return None,
663    })
664}
665
666mod device_text;
667mod render;
668
669// ─────────────────────────────── Tests ─────────────────────────────────────
670
671#[cfg(test)]
672mod tests;
673
674#[cfg(test)]
675mod property_metadata_tests;
676
677#[cfg(test)]
678mod acked_transitions_policy_tests;
679
680#[cfg(test)]
681mod truth_source_tests;
682
683#[cfg(test)]
684mod protocol_revision_tests;
685
686#[cfg(test)]
687mod identity_text_tests;
688
689#[cfg(test)]
690mod property_union_tests;
691
692#[cfg(test)]
693mod character_set_tests;
694
695#[cfg(test)]
696mod selected_device_tests;
697
698#[cfg(test)]
699mod creation_only_tests;
700
701#[cfg(test)]
702mod data_link_tests;
703
704#[cfg(test)]
705mod initiated_tests;