Skip to main content

rustbac_client/
pics.rs

1//! PICS (Protocol Implementation Conformance Statement) generation.
2//!
3//! Generates ASHRAE 135 Annex A compliant PICS documents describing
4//! the capabilities of a BACnet device.
5
6use rustbac_core::types::ObjectType;
7
8/// Segmentation support level.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum SegmentationSupport {
11    /// Both segmented requests and responses.
12    Both,
13    /// Segmented transmit only.
14    Transmit,
15    /// Segmented receive only.
16    Receive,
17    /// No segmentation support.
18    None,
19}
20
21impl SegmentationSupport {
22    fn as_str(&self) -> &'static str {
23        match self {
24            Self::Both => "Both",
25            Self::Transmit => "Transmit",
26            Self::Receive => "Receive",
27            Self::None => "None",
28        }
29    }
30}
31
32/// A BACnet service supported by the device.
33#[derive(Debug, Clone)]
34pub struct SupportedService {
35    /// Human-readable service name.
36    pub name: String,
37    /// Service choice number.
38    pub service_choice: u8,
39    /// Whether this device can initiate this service.
40    pub initiate: bool,
41    /// Whether this device can execute (respond to) this service.
42    pub execute: bool,
43}
44
45/// A BACnet object type supported by the device.
46#[derive(Debug, Clone)]
47pub struct SupportedObjectType {
48    /// The object type.
49    pub object_type: ObjectType,
50    /// Whether objects of this type can be dynamically created.
51    pub createable: bool,
52    /// Whether objects of this type can be dynamically deleted.
53    pub deletable: bool,
54}
55
56/// A PICS document describing device capabilities.
57///
58/// Can be rendered as human-readable text (Annex A format) or structured JSON.
59#[derive(Debug, Clone)]
60pub struct PicsDocument {
61    pub vendor_name: String,
62    pub product_name: String,
63    pub product_model_number: String,
64    pub firmware_revision: String,
65    pub protocol_version: u8,
66    pub protocol_revision: u16,
67    pub max_apdu_length: u16,
68    pub segmentation_supported: SegmentationSupport,
69    pub services: Vec<SupportedService>,
70    pub object_types: Vec<SupportedObjectType>,
71}
72
73impl PicsDocument {
74    /// Render the PICS document in ASHRAE 135 Annex A text format.
75    pub fn to_text(&self) -> String {
76        let mut out = String::new();
77        out.push_str("==============================================================================\n");
78        out.push_str("  BACnet Protocol Implementation Conformance Statement (PICS)\n");
79        out.push_str("==============================================================================\n\n");
80
81        out.push_str("-- Product Identification --\n\n");
82        out.push_str(&format!("  Vendor Name:            {}\n", self.vendor_name));
83        out.push_str(&format!("  Product Name:           {}\n", self.product_name));
84        out.push_str(&format!("  Product Model Number:   {}\n", self.product_model_number));
85        out.push_str(&format!("  Firmware Revision:      {}\n", self.firmware_revision));
86        out.push_str("\n");
87
88        out.push_str("-- BACnet Protocol --\n\n");
89        out.push_str(&format!("  BACnet Protocol Version:    {}\n", self.protocol_version));
90        out.push_str(&format!("  BACnet Protocol Revision:   {}\n", self.protocol_revision));
91        out.push_str(&format!("  Max APDU Length Accepted:   {}\n", self.max_apdu_length));
92        out.push_str(&format!("  Segmentation Supported:     {}\n", self.segmentation_supported.as_str()));
93        out.push_str("\n");
94
95        out.push_str("-- Supported Services --\n\n");
96        out.push_str("  Service                               Initiate  Execute\n");
97        out.push_str("  ------------------------------------  --------  -------\n");
98        for svc in &self.services {
99            let init = if svc.initiate { "Yes" } else { "No" };
100            let exec = if svc.execute { "Yes" } else { "No" };
101            out.push_str(&format!("  {:<38}  {:<8}  {}\n", svc.name, init, exec));
102        }
103        out.push_str("\n");
104
105        out.push_str("-- Supported Object Types --\n\n");
106        out.push_str("  Object Type                           Createable  Deletable\n");
107        out.push_str("  ------------------------------------  ----------  ---------\n");
108        for ot in &self.object_types {
109            let name = format!("{:?}", ot.object_type);
110            let create = if ot.createable { "Yes" } else { "No" };
111            let delete = if ot.deletable { "Yes" } else { "No" };
112            out.push_str(&format!("  {:<38}  {:<10}  {}\n", name, create, delete));
113        }
114
115        out
116    }
117
118    /// Render the PICS document as structured JSON.
119    pub fn to_json(&self) -> String {
120        let services_json: Vec<String> = self
121            .services
122            .iter()
123            .map(|s| {
124                format!(
125                    r#"    {{ "name": "{}", "service_choice": {}, "initiate": {}, "execute": {} }}"#,
126                    s.name, s.service_choice, s.initiate, s.execute
127                )
128            })
129            .collect();
130
131        let objects_json: Vec<String> = self
132            .object_types
133            .iter()
134            .map(|o| {
135                format!(
136                    r#"    {{ "object_type": "{:?}", "createable": {}, "deletable": {} }}"#,
137                    o.object_type, o.createable, o.deletable
138                )
139            })
140            .collect();
141
142        format!(
143            r#"{{
144  "vendor_name": "{}",
145  "product_name": "{}",
146  "product_model_number": "{}",
147  "firmware_revision": "{}",
148  "protocol_version": {},
149  "protocol_revision": {},
150  "max_apdu_length": {},
151  "segmentation_supported": "{}",
152  "services": [
153{}
154  ],
155  "object_types": [
156{}
157  ]
158}}"#,
159            self.vendor_name,
160            self.product_name,
161            self.product_model_number,
162            self.firmware_revision,
163            self.protocol_version,
164            self.protocol_revision,
165            self.max_apdu_length,
166            self.segmentation_supported.as_str(),
167            services_json.join(",\n"),
168            objects_json.join(",\n"),
169        )
170    }
171}
172
173/// Create a default PICS document pre-filled with all services and object types
174/// that rust-bac actually supports.
175pub fn default_rustbac_pics() -> PicsDocument {
176    PicsDocument {
177        vendor_name: "rust-bac".to_string(),
178        product_name: "rust-bac BACnet Stack".to_string(),
179        product_model_number: "rustbac-0.4".to_string(),
180        firmware_revision: env!("CARGO_PKG_VERSION").to_string(),
181        protocol_version: 1,
182        protocol_revision: 24,
183        max_apdu_length: 1476,
184        segmentation_supported: SegmentationSupport::Both,
185        services: vec![
186            SupportedService {
187                name: "ReadProperty".to_string(),
188                service_choice: 0x0C,
189                initiate: true,
190                execute: true,
191            },
192            SupportedService {
193                name: "ReadPropertyMultiple".to_string(),
194                service_choice: 0x0E,
195                initiate: true,
196                execute: true,
197            },
198            SupportedService {
199                name: "WriteProperty".to_string(),
200                service_choice: 0x0F,
201                initiate: true,
202                execute: true,
203            },
204            SupportedService {
205                name: "WritePropertyMultiple".to_string(),
206                service_choice: 0x10,
207                initiate: true,
208                execute: true,
209            },
210            SupportedService {
211                name: "Who-Is".to_string(),
212                service_choice: 0x08,
213                initiate: true,
214                execute: true,
215            },
216            SupportedService {
217                name: "I-Am".to_string(),
218                service_choice: 0x00,
219                initiate: true,
220                execute: false,
221            },
222            SupportedService {
223                name: "Who-Has".to_string(),
224                service_choice: 0x07,
225                initiate: true,
226                execute: false,
227            },
228            SupportedService {
229                name: "I-Have".to_string(),
230                service_choice: 0x01,
231                initiate: true,
232                execute: false,
233            },
234            SupportedService {
235                name: "SubscribeCOV".to_string(),
236                service_choice: 0x05,
237                initiate: true,
238                execute: true,
239            },
240            SupportedService {
241                name: "ConfirmedCOVNotification".to_string(),
242                service_choice: 0x01,
243                initiate: true,
244                execute: true,
245            },
246            SupportedService {
247                name: "UnconfirmedCOVNotification".to_string(),
248                service_choice: 0x02,
249                initiate: true,
250                execute: true,
251            },
252            SupportedService {
253                name: "SubscribeCOVProperty".to_string(),
254                service_choice: 0x1C,
255                initiate: true,
256                execute: false,
257            },
258            SupportedService {
259                name: "CreateObject".to_string(),
260                service_choice: 0x0A,
261                initiate: true,
262                execute: true,
263            },
264            SupportedService {
265                name: "DeleteObject".to_string(),
266                service_choice: 0x0B,
267                initiate: true,
268                execute: true,
269            },
270            SupportedService {
271                name: "GetAlarmSummary".to_string(),
272                service_choice: 0x03,
273                initiate: true,
274                execute: false,
275            },
276            SupportedService {
277                name: "GetEnrollmentSummary".to_string(),
278                service_choice: 0x04,
279                initiate: true,
280                execute: false,
281            },
282            SupportedService {
283                name: "GetEventInformation".to_string(),
284                service_choice: 0x1D,
285                initiate: true,
286                execute: false,
287            },
288            SupportedService {
289                name: "AcknowledgeAlarm".to_string(),
290                service_choice: 0x00,
291                initiate: true,
292                execute: false,
293            },
294            SupportedService {
295                name: "ConfirmedEventNotification".to_string(),
296                service_choice: 0x02,
297                initiate: false,
298                execute: true,
299            },
300            SupportedService {
301                name: "UnconfirmedEventNotification".to_string(),
302                service_choice: 0x03,
303                initiate: false,
304                execute: true,
305            },
306            SupportedService {
307                name: "AtomicReadFile".to_string(),
308                service_choice: 0x06,
309                initiate: true,
310                execute: false,
311            },
312            SupportedService {
313                name: "AtomicWriteFile".to_string(),
314                service_choice: 0x07,
315                initiate: true,
316                execute: false,
317            },
318            SupportedService {
319                name: "AddListElement".to_string(),
320                service_choice: 0x08,
321                initiate: true,
322                execute: false,
323            },
324            SupportedService {
325                name: "RemoveListElement".to_string(),
326                service_choice: 0x09,
327                initiate: true,
328                execute: false,
329            },
330            SupportedService {
331                name: "ReadRange".to_string(),
332                service_choice: 0x1A,
333                initiate: true,
334                execute: false,
335            },
336            SupportedService {
337                name: "DeviceCommunicationControl".to_string(),
338                service_choice: 0x11,
339                initiate: true,
340                execute: false,
341            },
342            SupportedService {
343                name: "ReinitializeDevice".to_string(),
344                service_choice: 0x14,
345                initiate: true,
346                execute: false,
347            },
348            SupportedService {
349                name: "TimeSynchronization".to_string(),
350                service_choice: 0x06,
351                initiate: true,
352                execute: false,
353            },
354            SupportedService {
355                name: "ConfirmedPrivateTransfer".to_string(),
356                service_choice: 0x12,
357                initiate: true,
358                execute: false,
359            },
360        ],
361        object_types: vec![
362            SupportedObjectType {
363                object_type: ObjectType::Device,
364                createable: false,
365                deletable: false,
366            },
367            SupportedObjectType {
368                object_type: ObjectType::AnalogInput,
369                createable: true,
370                deletable: true,
371            },
372            SupportedObjectType {
373                object_type: ObjectType::AnalogOutput,
374                createable: true,
375                deletable: true,
376            },
377            SupportedObjectType {
378                object_type: ObjectType::AnalogValue,
379                createable: true,
380                deletable: true,
381            },
382            SupportedObjectType {
383                object_type: ObjectType::BinaryInput,
384                createable: true,
385                deletable: true,
386            },
387            SupportedObjectType {
388                object_type: ObjectType::BinaryOutput,
389                createable: true,
390                deletable: true,
391            },
392            SupportedObjectType {
393                object_type: ObjectType::BinaryValue,
394                createable: true,
395                deletable: true,
396            },
397            SupportedObjectType {
398                object_type: ObjectType::MultiStateInput,
399                createable: true,
400                deletable: true,
401            },
402            SupportedObjectType {
403                object_type: ObjectType::MultiStateOutput,
404                createable: true,
405                deletable: true,
406            },
407            SupportedObjectType {
408                object_type: ObjectType::MultiStateValue,
409                createable: true,
410                deletable: true,
411            },
412            SupportedObjectType {
413                object_type: ObjectType::Schedule,
414                createable: false,
415                deletable: false,
416            },
417            SupportedObjectType {
418                object_type: ObjectType::Calendar,
419                createable: false,
420                deletable: false,
421            },
422            SupportedObjectType {
423                object_type: ObjectType::TrendLog,
424                createable: false,
425                deletable: false,
426            },
427            SupportedObjectType {
428                object_type: ObjectType::File,
429                createable: false,
430                deletable: false,
431            },
432            SupportedObjectType {
433                object_type: ObjectType::NotificationClass,
434                createable: false,
435                deletable: false,
436            },
437        ],
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn default_pics_has_services() {
447        let pics = default_rustbac_pics();
448        assert!(!pics.services.is_empty());
449        assert!(pics
450            .services
451            .iter()
452            .any(|s| s.name == "ReadProperty" && s.initiate && s.execute));
453        assert!(pics
454            .services
455            .iter()
456            .any(|s| s.name == "WriteProperty" && s.initiate && s.execute));
457        assert!(pics
458            .services
459            .iter()
460            .any(|s| s.name == "Who-Is" && s.initiate && s.execute));
461    }
462
463    #[test]
464    fn default_pics_has_object_types() {
465        let pics = default_rustbac_pics();
466        assert!(!pics.object_types.is_empty());
467        assert!(pics
468            .object_types
469            .iter()
470            .any(|o| o.object_type == ObjectType::Device && !o.createable));
471        assert!(pics
472            .object_types
473            .iter()
474            .any(|o| o.object_type == ObjectType::AnalogValue && o.createable));
475    }
476
477    #[test]
478    fn to_text_contains_vendor() {
479        let pics = default_rustbac_pics();
480        let text = pics.to_text();
481        assert!(text.contains("rust-bac"));
482        assert!(text.contains("ReadProperty"));
483        assert!(text.contains("Supported Services"));
484        assert!(text.contains("Supported Object Types"));
485    }
486
487    #[test]
488    fn to_json_parses() {
489        let pics = default_rustbac_pics();
490        let json = pics.to_json();
491        assert!(json.contains("\"vendor_name\""));
492        assert!(json.contains("\"services\""));
493        assert!(json.contains("\"object_types\""));
494        // Verify it's valid JSON by checking structure.
495        assert!(json.starts_with('{'));
496        assert!(json.ends_with('}'));
497    }
498}