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_types::bitstring::ServicesSupported;
12use bacnet_types::enums::{ObjectType, PropertyIdentifier, ServiceSupported};
13use bacnet_types::primitives::PropertyValue;
14
15use crate::server::ServerConfig;
16
17const PROPERTY_CAPABILITIES_EXPLANATION: &str = "Property rows aggregate configured instances: a row or access flag means at least one instance supports it. Actual availability and access depend on the concrete object. Optional is the metadata conformance classification; a required declaration wins, and absent rows do not vote.";
18
19/// How the text and Markdown PICS introduce a type's
20/// [`ObjectTypeSupport::creation_only_properties`]: read-only to
21/// WriteProperty as a whole, but a CreateObject initial value sets them.
22const CREATION_ONLY_LABEL: &str = "Whole value set only by CreateObject";
23
24/// A row's access as the text and Markdown PICS print it: the flags, and for
25/// a row writes of another property change
26/// ([`PropertySupport::written_through`]) a note saying so, since no write
27/// naming the row itself is taken (#1443).
28fn access_text(row: &PropertySupport) -> String {
29    match row.written_through {
30        Some(through) => format!(
31            "{} (resized through {through}: a whole write or its size at index 0)",
32            row.access
33        ),
34        None => row.access.to_string(),
35    }
36}
37
38/// The type's creation-only properties, comma-separated, or `None` if it
39/// has none.
40fn creation_only_names(support: &ObjectTypeSupport) -> Option<String> {
41    let names: Vec<_> = support
42        .creation_only_properties
43        .iter()
44        .map(ToString::to_string)
45        .collect();
46    (!names.is_empty()).then(|| names.join(", "))
47}
48
49// ───────────────────────────── Data model ──────────────────────────────────
50
51/// Complete PICS document per ASHRAE 135-2020 Annex A.
52#[derive(Debug, Clone)]
53pub struct Pics {
54    /// Vendor and device identification.
55    pub vendor_info: VendorInfo,
56    /// Device profile claimed.
57    pub device_profile: DeviceProfile,
58    /// Object types present in the database with their creation, deletion and property support.
59    pub supported_object_types: Vec<ObjectTypeSupport>,
60    /// Services the server can initiate or execute.
61    pub supported_services: Vec<ServiceSupport>,
62    /// Data-link layers the device supports.
63    pub data_link_layers: Vec<DataLinkSupport>,
64    /// Network-layer roles the device fills.
65    pub network_layer: NetworkLayerSupport,
66    /// Character sets the device supports.
67    pub character_sets: Vec<CharacterSet>,
68    /// Free-text descriptions of special functionality.
69    pub special_functionality: Vec<String>,
70}
71
72/// Vendor and device identification.
73#[derive(Debug, Clone)]
74pub struct VendorInfo {
75    /// Vendor identifier assigned by ASHRAE.
76    pub vendor_id: u16,
77    /// Vendor name.
78    pub vendor_name: String,
79    /// Product model name.
80    pub model_name: String,
81    /// Firmware revision string.
82    pub firmware_revision: String,
83    /// Application software version string.
84    pub application_software_version: String,
85    /// BACnet protocol version implemented.
86    pub protocol_version: u16,
87    /// BACnet protocol revision implemented.
88    pub protocol_revision: u16,
89}
90
91/// BACnet device profile.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum DeviceProfile {
94    /// BACnet Advanced Application Controller.
95    BAac,
96    /// BACnet Application Specific Controller.
97    BAsc,
98    /// BACnet Operator Workstation.
99    BOws,
100    /// BACnet Building Controller.
101    BBc,
102    /// BACnet Operator Panel.
103    BOp,
104    /// BACnet Router.
105    BRouter,
106    /// BACnet Gateway.
107    BGw,
108    /// BACnet Smart Controller.
109    BSc,
110    /// Custom / non-standard profile.
111    Custom(String),
112}
113
114impl fmt::Display for DeviceProfile {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        match self {
117            Self::BAac => f.write_str("B-AAC"),
118            Self::BAsc => f.write_str("B-ASC"),
119            Self::BOws => f.write_str("B-OWS"),
120            Self::BBc => f.write_str("B-BC"),
121            Self::BOp => f.write_str("B-OP"),
122            Self::BRouter => f.write_str("B-ROUTER"),
123            Self::BGw => f.write_str("B-GW"),
124            Self::BSc => f.write_str("B-SC"),
125            Self::Custom(s) => f.write_str(s),
126        }
127    }
128}
129
130/// Property capabilities across configured instances of an object type.
131/// Read/write flags mean at least one instance supports that access. Optional is
132/// true only when every present metadata row is optional; absent rows do not vote.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct PropertyAccess {
135    /// At least one instance can be read.
136    pub readable: bool,
137    /// At least one instance can be written.
138    pub writable: bool,
139    /// Every instance that has the property treats it as optional.
140    pub optional: bool,
141}
142
143impl fmt::Display for PropertyAccess {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        let r = if self.readable { "R" } else { "" };
146        let w = if self.writable { "W" } else { "" };
147        let o = if self.optional { "O" } else { "" };
148        write!(f, "{r}{w}{o}")
149    }
150}
151
152/// Supported property with its access flags.
153#[derive(Debug, Clone)]
154pub struct PropertySupport {
155    /// Property this row describes.
156    pub property_id: PropertyIdentifier,
157    /// Aggregated access flags for the property.
158    pub access: PropertyAccess,
159    /// The property whose writes change this one on some instance, although
160    /// a write naming this one is refused: State_Text, written whole or at
161    /// index 0, for a multi-state object's Number_Of_States (#1443).
162    pub written_through: Option<PropertyIdentifier>,
163}
164
165/// Object type support declaration.
166#[derive(Debug, Clone)]
167pub struct ObjectTypeSupport {
168    /// Object type described.
169    pub object_type: ObjectType,
170    /// Whether the type can be created remotely with CreateObject.
171    pub createable: bool,
172    /// The properties a CreateObject initial value may set whole on a type
173    /// that is createable, although WriteProperty can't change them later
174    /// (#1429). Empty when the type isn't createable. A property writes of
175    /// another one change, such as Number_Of_States, isn't listed: its row
176    /// says so instead ([`PropertySupport::written_through`]).
177    pub creation_only_properties: Vec<PropertyIdentifier>,
178    /// Whether the type can be deleted remotely with DeleteObject.
179    pub deleteable: bool,
180    /// Union of effective instance rows in ascending property-ID order.
181    pub supported_properties: Vec<PropertySupport>,
182}
183
184/// Service support declaration.
185#[derive(Debug, Clone)]
186pub struct ServiceSupport {
187    /// Service name.
188    pub service_name: String,
189    /// Device can request the service.
190    pub initiator: bool,
191    /// Device can execute the service.
192    pub executor: bool,
193}
194
195/// Data link layer support.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum DataLinkSupport {
198    /// BACnet/IP over IPv4.
199    BipV4,
200    /// BACnet/IP over IPv6.
201    BipV6,
202    /// MS/TP over RS-485.
203    Mstp,
204    /// Raw BACnet Ethernet (802.3).
205    Ethernet,
206    /// BACnet Secure Connect.
207    BacnetSc,
208}
209
210impl fmt::Display for DataLinkSupport {
211    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212        match self {
213            Self::BipV4 => f.write_str("BACnet/IP (Annex J)"),
214            Self::BipV6 => f.write_str("BACnet/IPv6 (Annex U)"),
215            Self::Mstp => f.write_str("MS/TP (Clause 9)"),
216            Self::Ethernet => f.write_str("BACnet Ethernet (Clause 7)"),
217            Self::BacnetSc => f.write_str("BACnet/SC (Annex AB)"),
218        }
219    }
220}
221
222/// Network layer capabilities.
223#[derive(Debug, Clone)]
224pub struct NetworkLayerSupport {
225    /// Device routes between networks.
226    pub router: bool,
227    /// Device acts as a BACnet Broadcast Management Device.
228    pub bbmd: bool,
229    /// Device can register as a foreign device with a BBMD.
230    pub foreign_device: bool,
231}
232
233/// A character set the PICS can claim under Annex A "Character Sets Supported".
234///
235/// The variants are exactly the six sets Annex A offers. Each discriminant is the
236/// set's code from Clause 20.2.9, the initial octet of an encoded CharacterString.
237/// `Display` prints the set's Annex A label. Claiming several sets does not mean
238/// the device can use them all at once.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
240#[repr(u8)]
241pub enum CharacterSet {
242    /// ISO 10646 UTF-8, code X'00'.
243    Utf8 = charset::UTF8,
244    /// IBM/Microsoft DBCS, code X'01'. Its strings carry a two-octet code page.
245    IbmMicrosoftDbcs = charset::IBM_MICROSOFT_DBCS,
246    /// JIS X 0208, code X'02'.
247    JisX0208 = charset::JIS_X_0208,
248    /// ISO 10646 UCS-4, code X'03'.
249    Ucs4 = charset::UCS4,
250    /// ISO 10646 UCS-2, code X'04'.
251    Ucs2 = charset::UCS2,
252    /// ISO 8859-1, code X'05'.
253    Iso8859_1 = charset::ISO_8859_1,
254}
255
256impl CharacterSet {
257    /// Every set Annex A offers, in Clause 20.2.9 code order.
258    pub const ALL: [Self; 6] = [
259        Self::Utf8,
260        Self::IbmMicrosoftDbcs,
261        Self::JisX0208,
262        Self::Ucs4,
263        Self::Ucs2,
264        Self::Iso8859_1,
265    ];
266
267    /// The set's Clause 20.2.9 code, one of the `bacnet_encoding::primitives::charset` constants.
268    pub const fn code(self) -> u8 {
269        self as u8
270    }
271}
272
273impl fmt::Display for CharacterSet {
274    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
275        f.write_str(match self {
276            Self::Utf8 => "ISO 10646 (UTF-8)",
277            Self::IbmMicrosoftDbcs => "IBM/Microsoft DBCS",
278            Self::JisX0208 => "JIS X 0208",
279            Self::Ucs4 => "ISO 10646 (UCS-4)",
280            Self::Ucs2 => "ISO 10646 (UCS-2)",
281            Self::Iso8859_1 => "ISO 8859-1",
282        })
283    }
284}
285
286// ────────────────────────────── Configuration ──────────────────────────────
287
288/// Configuration for PICS generation that cannot be inferred from the database.
289#[derive(Debug, Clone)]
290pub struct PicsConfig {
291    /// Vendor name.
292    pub vendor_name: String,
293    /// Product model name.
294    pub model_name: String,
295    /// Firmware revision string.
296    pub firmware_revision: String,
297    /// Application software version string.
298    pub application_software_version: String,
299    /// BACnet protocol version implemented (default 1).
300    pub protocol_version: u16,
301    /// BACnet protocol revision implemented (default 24).
302    pub protocol_revision: u16,
303    /// Device profile claimed.
304    pub device_profile: DeviceProfile,
305    /// Data-link layers the device supports.
306    pub data_link_layers: Vec<DataLinkSupport>,
307    /// Network-layer roles the device fills.
308    pub network_layer: NetworkLayerSupport,
309    /// Character sets the device supports.
310    pub character_sets: Vec<CharacterSet>,
311    /// Free-text descriptions of special functionality.
312    pub special_functionality: Vec<String>,
313}
314
315impl Default for PicsConfig {
316    fn default() -> Self {
317        Self {
318            vendor_name: String::new(),
319            model_name: String::new(),
320            firmware_revision: String::new(),
321            application_software_version: String::new(),
322            protocol_version: 1,
323            protocol_revision: 24,
324            device_profile: DeviceProfile::BAsc,
325            data_link_layers: vec![DataLinkSupport::BipV4],
326            network_layer: NetworkLayerSupport {
327                router: false,
328                bbmd: false,
329                foreign_device: false,
330            },
331            character_sets: vec![CharacterSet::Utf8],
332            special_functionality: Vec::new(),
333        }
334    }
335}
336
337// ────────────────────────────── Generator ──────────────────────────────────
338
339/// Generates PICS from raw object declarations and configuration. This standalone
340/// generator does not establish service execution; use `BACnetServer::generate_pics`
341/// for the full server's effective Device contract.
342pub struct PicsGenerator<'a> {
343    db: &'a ObjectDatabase,
344    server_config: &'a ServerConfig,
345    pics_config: &'a PicsConfig,
346    served: bool,
347}
348
349impl<'a> PicsGenerator<'a> {
350    /// Create a generator over the object database and the server and PICS configuration.
351    pub fn new(
352        db: &'a ObjectDatabase,
353        server_config: &'a ServerConfig,
354        pics_config: &'a PicsConfig,
355    ) -> Self {
356        Self {
357            db,
358            server_config,
359            pics_config,
360            served: false,
361        }
362    }
363
364    pub(crate) fn for_server(mut self) -> Self {
365        self.served = true;
366        self
367    }
368
369    /// Generate the complete PICS document.
370    pub fn generate(&self) -> Pics {
371        Pics {
372            vendor_info: self.build_vendor_info(),
373            device_profile: self.pics_config.device_profile.clone(),
374            supported_object_types: self.build_object_types(),
375            supported_services: self.build_services(),
376            data_link_layers: self.pics_config.data_link_layers.clone(),
377            network_layer: self.pics_config.network_layer.clone(),
378            character_sets: self.pics_config.character_sets.clone(),
379            special_functionality: self.pics_config.special_functionality.clone(),
380        }
381    }
382
383    fn build_vendor_info(&self) -> VendorInfo {
384        VendorInfo {
385            vendor_id: self.server_config.vendor_id,
386            vendor_name: self.pics_config.vendor_name.clone(),
387            model_name: self.pics_config.model_name.clone(),
388            firmware_revision: self.pics_config.firmware_revision.clone(),
389            application_software_version: self.pics_config.application_software_version.clone(),
390            protocol_version: self.pics_config.protocol_version,
391            protocol_revision: self.pics_config.protocol_revision,
392        }
393    }
394
395    fn build_object_types(&self) -> Vec<ObjectTypeSupport> {
396        let mut by_type: BTreeMap<u32, Vec<&dyn bacnet_objects::traits::BACnetObject>> =
397            BTreeMap::new();
398        for (_oid, obj) in self.db.iter_objects() {
399            by_type
400                .entry(obj.object_identifier().object_type().to_raw())
401                .or_default()
402                .push(obj);
403        }
404
405        let mut result = Vec::with_capacity(by_type.len());
406        for (raw_type, objects) in &by_type {
407            let object_type = ObjectType::from_raw(*raw_type);
408            let representative = objects[0];
409            let view = (self.served && object_type == ObjectType::DEVICE).then(|| {
410                crate::device_view::DeviceReadContext::new(
411                    self.db,
412                    crate::device_view::DeviceExecution::FullServer,
413                )
414            });
415            let supported_properties =
416                Self::union_property_support(objects.iter().flat_map(|object| {
417                    if let Some(view) = &view {
418                        Self::object_property_support(&view.object(*object))
419                    } else {
420                        Self::object_property_support(*object)
421                    }
422                }));
423
424            // Factory/deletion capabilities are type-level declarations, not
425            // per-instance property capabilities. Preserve that separate policy.
426
427            let createable = representative.is_createable();
428            let deleteable = representative.is_deleteable();
429            let written_through = |property: &PropertyIdentifier| {
430                supported_properties
431                    .iter()
432                    .any(|row| row.property_id == *property && row.written_through.is_some())
433            };
434            let creation_only_properties = if createable {
435                let mut properties = representative.creation_only_properties().to_vec();
436                properties.retain(|property| !written_through(property));
437                properties
438            } else {
439                Vec::new()
440            };
441
442            result.push(ObjectTypeSupport {
443                object_type,
444                createable,
445                creation_only_properties,
446                deleteable,
447                supported_properties,
448            });
449        }
450        result
451    }
452
453    fn object_property_support(
454        object: &dyn bacnet_objects::traits::BACnetObject,
455    ) -> Vec<PropertySupport> {
456        let metadata = object.property_metadata();
457        if metadata.is_empty() {
458            let all_props = object.property_list();
459            let required = object.required_properties();
460            all_props
461                .iter()
462                .map(|&property_id| PropertySupport {
463                    property_id,
464                    access: PropertyAccess {
465                        readable: true,
466                        writable: object.is_writable_property(property_id),
467                        optional: !required.contains(&property_id),
468                    },
469                    written_through: None,
470                })
471                .collect()
472        } else {
473            metadata
474                .iter()
475                .map(|row| PropertySupport {
476                    property_id: row.property_identifier,
477                    access: PropertyAccess {
478                        readable: true,
479                        writable: row.write_capability.is_writable(),
480                        optional: !row.is_required(),
481                    },
482                    written_through: row.write_capability.written_through(),
483                })
484                .collect()
485        }
486    }
487
488    fn union_property_support(
489        rows: impl IntoIterator<Item = PropertySupport>,
490    ) -> Vec<PropertySupport> {
491        // A type row records capabilities across all configured instances.
492        // Missing properties do not vote on conformance; a required present row
493        // wins over optional rows. Sort even a single instance by property ID.
494        let mut properties: BTreeMap<u32, PropertySupport> = BTreeMap::new();
495        for row in rows {
496            properties
497                .entry(row.property_id.to_raw())
498                .and_modify(|existing| {
499                    existing.access.readable |= row.access.readable;
500                    existing.access.writable |= row.access.writable;
501                    existing.access.optional &= row.access.optional;
502                    existing.written_through = existing.written_through.or(row.written_through);
503                })
504                .or_insert(row);
505        }
506        properties.into_values().collect()
507    }
508
509    /// Build the service support list based on what the server actually handles.
510    /// Services this server initiates (the PICS initiator column): replies
511    /// and notifications constructed outbound by `bacnet-server`. Distinct
512    /// from [`EXECUTED_SERVICES`](bacnet_objects::device::EXECUTED_SERVICES), which Clause 12.11 ties to execution.
513    const INITIATED_SERVICES: &'static [ServiceSupported] = &[
514        ServiceSupported::I_AM,
515        ServiceSupported::I_HAVE,
516        ServiceSupported::CONFIRMED_COV_NOTIFICATION,
517        ServiceSupported::CONFIRMED_EVENT_NOTIFICATION,
518        ServiceSupported::UNCONFIRMED_COV_NOTIFICATION,
519        ServiceSupported::UNCONFIRMED_EVENT_NOTIFICATION,
520        ServiceSupported::CONFIRMED_COV_NOTIFICATION_MULTIPLE,
521        ServiceSupported::UNCONFIRMED_COV_NOTIFICATION_MULTIPLE,
522    ];
523
524    fn build_services(&self) -> Vec<ServiceSupport> {
525        // Standalone callers inspect the selected Device's raw declaration. The
526        // full server always uses its fixed execution profile, filtered by
527        // database clock availability; a mutable/custom Device cannot override
528        // that contract.
529        let effective_executed = (!self.served)
530            .then(|| {
531                let device = self.db.get(&self.db.selected_device()?)?;
532                match device
533                    .read_property(PropertyIdentifier::PROTOCOL_SERVICES_SUPPORTED, None)
534                    .ok()?
535                {
536                    PropertyValue::BitString { data, .. } => {
537                        Some(ServicesSupported::from_bacnet(&data))
538                    }
539                    _ => None,
540                }
541            })
542            .flatten();
543        let mut service_map: BTreeMap<&'static str, (bool, bool)> = BTreeMap::new();
544        let clock_available = self.db.clock_frame().is_some();
545        let executed: Box<dyn Iterator<Item = ServiceSupported> + '_> = match &effective_executed {
546            Some(services) => Box::new(services.iter()),
547            None => {
548                Box::new(crate::device_view::DeviceExecution::FullServer.services(clock_available))
549            }
550        };
551        for service in executed {
552            service_map
553                .entry(service_display_name(service))
554                .or_default()
555                .1 = true;
556        }
557        for service in Self::INITIATED_SERVICES {
558            service_map
559                .entry(service_display_name(*service))
560                .or_default()
561                .0 = true;
562        }
563
564        service_map
565            .into_iter()
566            .map(|(name, (initiator, executor))| ServiceSupport {
567                service_name: name.to_string(),
568                initiator,
569                executor,
570            })
571            .collect()
572    }
573}
574
575// ─────────────────────────── Text output ───────────────────────────────────
576
577impl Pics {
578    /// Render the PICS as human-readable text per Annex A layout.
579    pub fn generate_text(&self) -> String {
580        let mut out = String::with_capacity(4096);
581
582        out.push_str("=== BACnet Protocol Implementation Conformance Statement (PICS) ===\n");
583        out.push_str("    Per ASHRAE 135-2020 Annex A\n\n");
584
585        out.push_str("--- Vendor Information ---\n");
586        out.push_str(&format!(
587            "Vendor ID:                      {}\n",
588            self.vendor_info.vendor_id
589        ));
590        out.push_str(&format!(
591            "Vendor Name:                    {}\n",
592            self.vendor_info.vendor_name
593        ));
594        out.push_str(&format!(
595            "Model Name:                     {}\n",
596            self.vendor_info.model_name
597        ));
598        out.push_str(&format!(
599            "Firmware Revision:              {}\n",
600            self.vendor_info.firmware_revision
601        ));
602        out.push_str(&format!(
603            "Application Software Version:   {}\n",
604            self.vendor_info.application_software_version
605        ));
606        out.push_str(&format!(
607            "Protocol Version:               {}\n",
608            self.vendor_info.protocol_version
609        ));
610        out.push_str(&format!(
611            "Protocol Revision:              {}\n\n",
612            self.vendor_info.protocol_revision
613        ));
614
615        out.push_str("--- BACnet Device Profile ---\n");
616        out.push_str(&format!("Profile: {}\n\n", self.device_profile));
617
618        out.push_str("--- Supported Object Types ---\n");
619        out.push_str(PROPERTY_CAPABILITIES_EXPLANATION);
620        out.push('\n');
621        for ot in &self.supported_object_types {
622            out.push_str(&format!(
623                "\n  Object Type: {} (createable={}, deleteable={})\n",
624                ot.object_type, ot.createable, ot.deleteable
625            ));
626            if let Some(names) = creation_only_names(ot) {
627                out.push_str(&format!("  {CREATION_ONLY_LABEL}: {names}\n"));
628            }
629            out.push_str("  Properties:\n");
630            for prop in &ot.supported_properties {
631                out.push_str(&format!(
632                    "    {:<40} {}\n",
633                    prop.property_id.to_string(),
634                    access_text(prop)
635                ));
636            }
637        }
638        out.push('\n');
639
640        out.push_str("--- Supported Services ---\n");
641        out.push_str(&format!(
642            "  {:<45} {:>9} {:>9}\n",
643            "Service", "Initiator", "Executor"
644        ));
645        out.push_str(&format!("  {:-<45} {:-<9} {:-<9}\n", "", "", ""));
646        for svc in &self.supported_services {
647            let init = if svc.initiator { "Yes" } else { "No" };
648            let exec = if svc.executor { "Yes" } else { "No" };
649            out.push_str(&format!(
650                "  {:<45} {:>9} {:>9}\n",
651                svc.service_name, init, exec
652            ));
653        }
654        out.push('\n');
655
656        out.push_str("--- Data Link Layer Support ---\n");
657        for dl in &self.data_link_layers {
658            out.push_str(&format!("  {dl}\n"));
659        }
660        out.push('\n');
661
662        out.push_str("--- Network Layer Options ---\n");
663        out.push_str(&format!(
664            "  Router:         {}\n",
665            self.network_layer.router
666        ));
667        out.push_str(&format!("  BBMD:           {}\n", self.network_layer.bbmd));
668        out.push_str(&format!(
669            "  Foreign Device: {}\n\n",
670            self.network_layer.foreign_device
671        ));
672
673        out.push_str("--- Character Sets Supported ---\n");
674        for cs in &self.character_sets {
675            out.push_str(&format!("  {cs}\n"));
676        }
677        out.push('\n');
678
679        if !self.special_functionality.is_empty() {
680            out.push_str("--- Special Functionality ---\n");
681            for sf in &self.special_functionality {
682                out.push_str(&format!("  {sf}\n"));
683            }
684            out.push('\n');
685        }
686
687        out
688    }
689
690    /// Render the PICS as Markdown for documentation.
691    pub fn generate_markdown(&self) -> String {
692        let mut out = String::with_capacity(4096);
693
694        out.push_str("# BACnet Protocol Implementation Conformance Statement (PICS)\n\n");
695        out.push_str("*Per ASHRAE 135-2020 Annex A*\n\n");
696
697        out.push_str("## Vendor Information\n\n");
698        out.push_str("| Field | Value |\n");
699        out.push_str("|-------|-------|\n");
700        out.push_str(&format!("| Vendor ID | {} |\n", self.vendor_info.vendor_id));
701        out.push_str(&format!(
702            "| Vendor Name | {} |\n",
703            self.vendor_info.vendor_name
704        ));
705        out.push_str(&format!(
706            "| Model Name | {} |\n",
707            self.vendor_info.model_name
708        ));
709        out.push_str(&format!(
710            "| Firmware Revision | {} |\n",
711            self.vendor_info.firmware_revision
712        ));
713        out.push_str(&format!(
714            "| Application Software Version | {} |\n",
715            self.vendor_info.application_software_version
716        ));
717        out.push_str(&format!(
718            "| Protocol Version | {} |\n",
719            self.vendor_info.protocol_version
720        ));
721        out.push_str(&format!(
722            "| Protocol Revision | {} |\n\n",
723            self.vendor_info.protocol_revision
724        ));
725
726        out.push_str("## BACnet Device Profile\n\n");
727        out.push_str(&format!("**{}**\n\n", self.device_profile));
728
729        out.push_str("## Supported Object Types\n\n");
730        out.push_str(PROPERTY_CAPABILITIES_EXPLANATION);
731        out.push_str("\n\n");
732        for ot in &self.supported_object_types {
733            out.push_str(&format!(
734                "### {}\n\n- Createable: {}\n- Deleteable: {}\n",
735                ot.object_type, ot.createable, ot.deleteable
736            ));
737            if let Some(names) = creation_only_names(ot) {
738                out.push_str(&format!("- {CREATION_ONLY_LABEL}: {names}\n"));
739            }
740            out.push('\n');
741            out.push_str("| Property | Access |\n");
742            out.push_str("|----------|--------|\n");
743            for prop in &ot.supported_properties {
744                out.push_str(&format!(
745                    "| {} | {} |\n",
746                    prop.property_id,
747                    access_text(prop)
748                ));
749            }
750            out.push('\n');
751        }
752
753        out.push_str("## Supported Services\n\n");
754        out.push_str("| Service | Initiator | Executor |\n");
755        out.push_str("|---------|-----------|----------|\n");
756        for svc in &self.supported_services {
757            let init = if svc.initiator { "Yes" } else { "No" };
758            let exec = if svc.executor { "Yes" } else { "No" };
759            out.push_str(&format!("| {} | {} | {} |\n", svc.service_name, init, exec));
760        }
761        out.push('\n');
762
763        out.push_str("## Data Link Layer Support\n\n");
764        for dl in &self.data_link_layers {
765            out.push_str(&format!("- {dl}\n"));
766        }
767        out.push('\n');
768
769        out.push_str("## Network Layer Options\n\n");
770        out.push_str("| Feature | Supported |\n");
771        out.push_str("|---------|-----------|\n");
772        out.push_str(&format!("| Router | {} |\n", self.network_layer.router));
773        out.push_str(&format!("| BBMD | {} |\n", self.network_layer.bbmd));
774        out.push_str(&format!(
775            "| Foreign Device | {} |\n\n",
776            self.network_layer.foreign_device
777        ));
778
779        out.push_str("## Character Sets Supported\n\n");
780        for cs in &self.character_sets {
781            out.push_str(&format!("- {cs}\n"));
782        }
783        out.push('\n');
784
785        if !self.special_functionality.is_empty() {
786            out.push_str("## Special Functionality\n\n");
787            for sf in &self.special_functionality {
788                out.push_str(&format!("- {sf}\n"));
789            }
790            out.push('\n');
791        }
792
793        out
794    }
795}
796
797// ─────────────────────────── Standalone helper ─────────────────────────────
798
799/// Generate a PICS document from an ObjectDatabase and configuration.
800///
801/// This is a convenience function for use without a running BACnetServer.
802pub fn generate_pics(
803    db: &ObjectDatabase,
804    server_config: &ServerConfig,
805    pics_config: &PicsConfig,
806) -> Pics {
807    PicsGenerator::new(db, server_config, pics_config).generate()
808}
809
810// ─────────────────────────────── Tests ─────────────────────────────────────
811
812/// PICS display name for a `BACnetServicesSupported` bit position.
813fn service_display_name(service: ServiceSupported) -> &'static str {
814    match service.to_raw() {
815        0 => "AcknowledgeAlarm",
816        1 => "ConfirmedCOVNotification",
817        2 => "ConfirmedEventNotification",
818        3 => "GetAlarmSummary",
819        4 => "GetEnrollmentSummary",
820        5 => "SubscribeCOV",
821        6 => "AtomicReadFile",
822        7 => "AtomicWriteFile",
823        8 => "AddListElement",
824        9 => "RemoveListElement",
825        10 => "CreateObject",
826        11 => "DeleteObject",
827        12 => "ReadProperty",
828        14 => "ReadPropertyMultiple",
829        15 => "WriteProperty",
830        16 => "WritePropertyMultiple",
831        17 => "DeviceCommunicationControl",
832        18 => "ConfirmedPrivateTransfer",
833        19 => "ConfirmedTextMessage",
834        20 => "ReinitializeDevice",
835        21 => "VT-Open",
836        22 => "VT-Close",
837        23 => "VT-Data",
838        26 => "I-Am",
839        27 => "I-Have",
840        28 => "UnconfirmedCOVNotification",
841        29 => "UnconfirmedEventNotification",
842        30 => "UnconfirmedPrivateTransfer",
843        31 => "UnconfirmedTextMessage",
844        32 => "TimeSynchronization",
845        33 => "WhoHas",
846        34 => "WhoIs",
847        35 => "ReadRange",
848        36 => "UTCTimeSynchronization",
849        37 => "LifeSafetyOperation",
850        38 => "SubscribeCOVProperty",
851        39 => "GetEventInformation",
852        40 => "WriteGroup",
853        41 => "SubscribeCOVPropertyMultiple",
854        42 => "ConfirmedCOVNotificationMultiple",
855        43 => "UnconfirmedCOVNotificationMultiple",
856        44 => "ConfirmedAuditNotification",
857        45 => "AuditLogQuery",
858        46 => "UnconfirmedAuditNotification",
859        47 => "Who-Am-I",
860        48 => "You-Are",
861        _ => "Unknown",
862    }
863}
864
865#[cfg(test)]
866mod tests;
867
868#[cfg(test)]
869mod property_metadata_tests;
870
871#[cfg(test)]
872mod acked_transitions_policy_tests;
873
874#[cfg(test)]
875mod truth_source_tests;
876
877#[cfg(test)]
878mod property_union_tests;
879
880#[cfg(test)]
881mod character_set_tests;
882
883#[cfg(test)]
884mod selected_device_tests;
885
886#[cfg(test)]
887mod creation_only_tests;