Skip to main content

bacnet_objects/
loop_obj.rs

1//! Loop (type 12) object per ASHRAE 135-2020 Clause 12.19.
2//!
3//! PID control loop. The application is responsible for running the PID
4//! algorithm; this object stores configuration and current output.
5
6use bacnet_types::constructed::BACnetObjectPropertyReference;
7use bacnet_types::enums::{ErrorClass, ErrorCode, ObjectType, PropertyIdentifier};
8use bacnet_types::error::Error;
9use bacnet_types::primitives::{ObjectIdentifier, PropertyValue, StatusFlags};
10use std::borrow::Cow;
11
12use crate::common::{self, read_property_list_property};
13use crate::traits::BACnetObject;
14
15/// BACnet Loop object — PID control loop configuration and state.
16pub struct LoopObject {
17    oid: ObjectIdentifier,
18    name: String,
19    description: String,
20    present_value: f32,
21    setpoint: f32,
22    proportional_constant: f32,
23    integral_constant: f32,
24    derivative_constant: f32,
25    output_units: u32,
26    update_interval: u32,
27    out_of_service: bool,
28    reliability: u32,
29    /// Evaluated Reliability saved while a client simulation owns the property
30    /// (Out_Of_Service TRUE); restored on the return to service.
31    reliability_before_out_of_service: Option<u32>,
32    status_flags: StatusFlags,
33    controlled_variable_reference: Option<BACnetObjectPropertyReference>,
34    manipulated_variable_reference: Option<BACnetObjectPropertyReference>,
35    setpoint_reference: Option<BACnetObjectPropertyReference>,
36}
37
38impl LoopObject {
39    pub fn new(instance: u32, name: impl Into<String>, output_units: u32) -> Result<Self, Error> {
40        let oid = ObjectIdentifier::new(ObjectType::LOOP, instance)?;
41        Ok(Self {
42            oid,
43            name: name.into(),
44            description: String::new(),
45            present_value: 0.0,
46            setpoint: 0.0,
47            proportional_constant: 1.0,
48            integral_constant: 0.0,
49            derivative_constant: 0.0,
50            output_units,
51            update_interval: 1000, // milliseconds
52            out_of_service: false,
53            reliability: 0,
54            reliability_before_out_of_service: None,
55            status_flags: StatusFlags::empty(),
56            controlled_variable_reference: None,
57            manipulated_variable_reference: None,
58            setpoint_reference: None,
59        })
60    }
61
62    /// Application sets the current output value after PID computation.
63    pub fn set_present_value(&mut self, value: f32) {
64        self.present_value = value;
65    }
66
67    /// Set the description string.
68    pub fn set_description(&mut self, desc: impl Into<String>) {
69        self.description = desc.into();
70    }
71
72    /// Set the controlled variable reference (the object whose present value is
73    /// being controlled by this loop).
74    pub fn set_controlled_variable_reference(&mut self, r: BACnetObjectPropertyReference) {
75        self.controlled_variable_reference = Some(r);
76    }
77
78    /// Set the manipulated variable reference (the object that the loop output
79    /// drives to achieve the setpoint).
80    pub fn set_manipulated_variable_reference(&mut self, r: BACnetObjectPropertyReference) {
81        self.manipulated_variable_reference = Some(r);
82    }
83
84    /// Set the setpoint reference (an alternative way to supply the setpoint
85    /// from another object's property instead of the inline `SETPOINT` value).
86    pub fn set_setpoint_reference(&mut self, r: BACnetObjectPropertyReference) {
87        self.setpoint_reference = Some(r);
88    }
89}
90
91impl BACnetObject for LoopObject {
92    fn object_identifier(&self) -> ObjectIdentifier {
93        self.oid
94    }
95
96    fn object_name(&self) -> &str {
97        &self.name
98    }
99
100    fn read_property(
101        &self,
102        property: PropertyIdentifier,
103        array_index: Option<u32>,
104    ) -> Result<PropertyValue, Error> {
105        match property {
106            p if p == PropertyIdentifier::OBJECT_IDENTIFIER => {
107                Ok(PropertyValue::ObjectIdentifier(self.oid))
108            }
109            p if p == PropertyIdentifier::OBJECT_NAME => {
110                Ok(PropertyValue::CharacterString(self.name.clone()))
111            }
112            p if p == PropertyIdentifier::DESCRIPTION => {
113                Ok(PropertyValue::CharacterString(self.description.clone()))
114            }
115            p if p == PropertyIdentifier::OBJECT_TYPE => {
116                Ok(PropertyValue::Enumerated(ObjectType::LOOP.to_raw()))
117            }
118            p if p == PropertyIdentifier::PRESENT_VALUE => {
119                Ok(PropertyValue::Real(self.present_value))
120            }
121            p if p == PropertyIdentifier::SETPOINT => Ok(PropertyValue::Real(self.setpoint)),
122            p if p == PropertyIdentifier::PROPORTIONAL_CONSTANT => {
123                Ok(PropertyValue::Real(self.proportional_constant))
124            }
125            p if p == PropertyIdentifier::INTEGRAL_CONSTANT => {
126                Ok(PropertyValue::Real(self.integral_constant))
127            }
128            p if p == PropertyIdentifier::DERIVATIVE_CONSTANT => {
129                Ok(PropertyValue::Real(self.derivative_constant))
130            }
131            p if p == PropertyIdentifier::OUTPUT_UNITS => {
132                Ok(PropertyValue::Enumerated(self.output_units))
133            }
134            p if p == PropertyIdentifier::UPDATE_INTERVAL => {
135                Ok(PropertyValue::Unsigned(self.update_interval as u64))
136            }
137            p if p == PropertyIdentifier::STATUS_FLAGS => Ok(PropertyValue::BitString {
138                unused_bits: 4,
139                data: vec![self.status_flags.bits() << 4],
140            }),
141            p if p == PropertyIdentifier::EVENT_STATE => Ok(PropertyValue::Enumerated(0)),
142            p if p == PropertyIdentifier::RELIABILITY => {
143                Ok(PropertyValue::Enumerated(self.reliability))
144            }
145            p if p == PropertyIdentifier::OUT_OF_SERVICE => {
146                Ok(PropertyValue::Boolean(self.out_of_service))
147            }
148            p if p == PropertyIdentifier::CONTROLLED_VARIABLE_REFERENCE => Ok(
149                crate::reference::reference_read_value(&self.controlled_variable_reference),
150            ),
151            p if p == PropertyIdentifier::MANIPULATED_VARIABLE_REFERENCE => Ok(
152                crate::reference::reference_read_value(&self.manipulated_variable_reference),
153            ),
154            p if p == PropertyIdentifier::SETPOINT_REFERENCE => Ok(
155                crate::reference::reference_read_value(&self.setpoint_reference),
156            ),
157            p if p == PropertyIdentifier::PROPERTY_LIST => {
158                read_property_list_property(&self.property_list(), array_index)
159            }
160            _ => Err(Error::Protocol {
161                class: ErrorClass::PROPERTY.to_raw() as u32,
162                code: ErrorCode::UNKNOWN_PROPERTY.to_raw() as u32,
163            }),
164        }
165    }
166
167    fn write_property(
168        &mut self,
169        property: PropertyIdentifier,
170        _array_index: Option<u32>,
171        value: PropertyValue,
172        _priority: Option<u8>,
173    ) -> Result<(), Error> {
174        if let Some(result) = common::write_out_of_service_with_reliability_restore(
175            &mut self.out_of_service,
176            &mut self.reliability,
177            &mut self.reliability_before_out_of_service,
178            property,
179            &value,
180        ) {
181            return result;
182        }
183        match property {
184            p if p == PropertyIdentifier::SETPOINT => {
185                if let PropertyValue::Real(v) = value {
186                    common::reject_non_finite(v)?;
187                    self.setpoint = v;
188                    return Ok(());
189                }
190                Err(Error::Protocol {
191                    class: ErrorClass::PROPERTY.to_raw() as u32,
192                    code: ErrorCode::INVALID_DATA_TYPE.to_raw() as u32,
193                })
194            }
195            p if p == PropertyIdentifier::PROPORTIONAL_CONSTANT => {
196                if let PropertyValue::Real(v) = value {
197                    common::reject_non_finite(v)?;
198                    self.proportional_constant = v;
199                    return Ok(());
200                }
201                Err(Error::Protocol {
202                    class: ErrorClass::PROPERTY.to_raw() as u32,
203                    code: ErrorCode::INVALID_DATA_TYPE.to_raw() as u32,
204                })
205            }
206            p if p == PropertyIdentifier::INTEGRAL_CONSTANT => {
207                if let PropertyValue::Real(v) = value {
208                    common::reject_non_finite(v)?;
209                    self.integral_constant = v;
210                    return Ok(());
211                }
212                Err(Error::Protocol {
213                    class: ErrorClass::PROPERTY.to_raw() as u32,
214                    code: ErrorCode::INVALID_DATA_TYPE.to_raw() as u32,
215                })
216            }
217            p if p == PropertyIdentifier::DERIVATIVE_CONSTANT => {
218                if let PropertyValue::Real(v) = value {
219                    common::reject_non_finite(v)?;
220                    self.derivative_constant = v;
221                    return Ok(());
222                }
223                Err(Error::Protocol {
224                    class: ErrorClass::PROPERTY.to_raw() as u32,
225                    code: ErrorCode::INVALID_DATA_TYPE.to_raw() as u32,
226                })
227            }
228            p if p == PropertyIdentifier::UPDATE_INTERVAL => {
229                if let PropertyValue::Unsigned(v) = value {
230                    self.update_interval = common::u64_to_u32(v)?;
231                    return Ok(());
232                }
233                Err(Error::Protocol {
234                    class: ErrorClass::PROPERTY.to_raw() as u32,
235                    code: ErrorCode::INVALID_DATA_TYPE.to_raw() as u32,
236                })
237            }
238            // Clause 12.17 Table 12-20 lists Reliability O7, whose footnote reads
239            // "These properties are required to be writable when Out_Of_Service
240            // is TRUE", and the Out_Of_Service property text then narrows the grant:
241            // while TRUE, "the Present_Value property and the Reliability property,
242            // if present and capable of taking on values other than
243            // NO_FAULT_DETECTED, shall be writable to allow simulating specific
244            // conditions or for testing purposes". In service the property is owned
245            // by the algorithm, so a network write is refused here; the internal
246            // evaluator route is `set_reliability_internal` with the complementary
247            // guard, and Out_Of_Service saves/restores the evaluated value (handled
248            // above the match).
249            p if p == PropertyIdentifier::RELIABILITY => {
250                if !self.out_of_service {
251                    return Err(common::write_access_denied_error());
252                }
253                if let PropertyValue::Enumerated(v) = value {
254                    if !common::is_reliability_value_valid(v) {
255                        return Err(common::value_out_of_range_error());
256                    }
257                    self.reliability = v;
258                    return Ok(());
259                }
260                Err(Error::Protocol {
261                    class: ErrorClass::PROPERTY.to_raw() as u32,
262                    code: ErrorCode::INVALID_DATA_TYPE.to_raw() as u32,
263                })
264            }
265            p if p == PropertyIdentifier::DESCRIPTION => {
266                if let PropertyValue::CharacterString(s) = value {
267                    self.description = s;
268                    return Ok(());
269                }
270                Err(Error::Protocol {
271                    class: ErrorClass::PROPERTY.to_raw() as u32,
272                    code: ErrorCode::INVALID_DATA_TYPE.to_raw() as u32,
273                })
274            }
275            // Clause 12.17 / Clause 21 BACnetObjectPropertyReference: the
276            // write value decodes via the shared arm helper — legacy local
277            // List and framed network (context-tagged members) forms both
278            // land strictly; see reference.rs.
279            p if p == PropertyIdentifier::CONTROLLED_VARIABLE_REFERENCE => {
280                self.controlled_variable_reference = crate::reference::decode_reference_write(
281                    &value,
282                    crate::reference::ReferenceFrame::Bare,
283                )?;
284                Ok(())
285            }
286            p if p == PropertyIdentifier::MANIPULATED_VARIABLE_REFERENCE => {
287                self.manipulated_variable_reference = crate::reference::decode_reference_write(
288                    &value,
289                    crate::reference::ReferenceFrame::Bare,
290                )?;
291                Ok(())
292            }
293            // Setpoint_Reference is typed BACnetSetpointReference (Clause
294            // 12.17): the reference may additionally arrive inside the
295            // production's opening/closing tag [0] frame on the wire.
296            p if p == PropertyIdentifier::SETPOINT_REFERENCE => {
297                self.setpoint_reference = crate::reference::decode_reference_write(
298                    &value,
299                    crate::reference::ReferenceFrame::Setpoint,
300                )?;
301                Ok(())
302            }
303            _ => Err(Error::Protocol {
304                class: ErrorClass::PROPERTY.to_raw() as u32,
305                code: ErrorCode::WRITE_ACCESS_DENIED.to_raw() as u32,
306            }),
307        }
308    }
309
310    fn property_list(&self) -> Cow<'static, [PropertyIdentifier]> {
311        static PROPS: &[PropertyIdentifier] = &[
312            PropertyIdentifier::OBJECT_IDENTIFIER,
313            PropertyIdentifier::OBJECT_NAME,
314            PropertyIdentifier::DESCRIPTION,
315            PropertyIdentifier::OBJECT_TYPE,
316            PropertyIdentifier::PRESENT_VALUE,
317            PropertyIdentifier::SETPOINT,
318            PropertyIdentifier::PROPORTIONAL_CONSTANT,
319            PropertyIdentifier::INTEGRAL_CONSTANT,
320            PropertyIdentifier::DERIVATIVE_CONSTANT,
321            PropertyIdentifier::OUTPUT_UNITS,
322            PropertyIdentifier::UPDATE_INTERVAL,
323            PropertyIdentifier::STATUS_FLAGS,
324            PropertyIdentifier::EVENT_STATE,
325            PropertyIdentifier::RELIABILITY,
326            PropertyIdentifier::OUT_OF_SERVICE,
327            PropertyIdentifier::CONTROLLED_VARIABLE_REFERENCE,
328            PropertyIdentifier::MANIPULATED_VARIABLE_REFERENCE,
329            PropertyIdentifier::SETPOINT_REFERENCE,
330        ];
331        Cow::Borrowed(PROPS)
332    }
333
334    fn supports_cov(&self) -> bool {
335        true
336    }
337
338    fn set_reliability_internal(&mut self, reliability: u32) -> Result<(), Error> {
339        // While Out_Of_Service is TRUE the client owns the simulated value;
340        // an internal write would clobber the simulation (Clause 12.17
341        // Out_Of_Service paragraph: Reliability is "decoupled from the
342        // algorithm"), so it is refused until the object returns to service.
343        if self.out_of_service {
344            return Err(common::write_access_denied_error());
345        }
346        if !common::is_reliability_value_valid(reliability) {
347            return Err(common::value_out_of_range_error());
348        }
349        self.reliability = reliability;
350        Ok(())
351    }
352
353    fn is_writable_property(&self, property: PropertyIdentifier) -> bool {
354        // Mirrors the LoopObject `write_property` arms so the PICS and
355        // runtime dispatch share one truth source.
356        matches!(
357            property,
358            PropertyIdentifier::SETPOINT
359                | PropertyIdentifier::PROPORTIONAL_CONSTANT
360                | PropertyIdentifier::INTEGRAL_CONSTANT
361                | PropertyIdentifier::DERIVATIVE_CONSTANT
362                | PropertyIdentifier::UPDATE_INTERVAL
363                | PropertyIdentifier::RELIABILITY
364                | PropertyIdentifier::OUT_OF_SERVICE
365                | PropertyIdentifier::DESCRIPTION
366                | PropertyIdentifier::CONTROLLED_VARIABLE_REFERENCE
367                | PropertyIdentifier::MANIPULATED_VARIABLE_REFERENCE
368                | PropertyIdentifier::SETPOINT_REFERENCE
369        )
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn loop_read_defaults() {
379        let lo = LoopObject::new(1, "LOOP-1", 62).unwrap();
380        assert_eq!(
381            lo.read_property(PropertyIdentifier::PRESENT_VALUE, None)
382                .unwrap(),
383            PropertyValue::Real(0.0)
384        );
385        assert_eq!(
386            lo.read_property(PropertyIdentifier::SETPOINT, None)
387                .unwrap(),
388            PropertyValue::Real(0.0)
389        );
390        assert_eq!(
391            lo.read_property(PropertyIdentifier::PROPORTIONAL_CONSTANT, None)
392                .unwrap(),
393            PropertyValue::Real(1.0)
394        );
395    }
396
397    #[test]
398    fn loop_write_pid_constants() {
399        let mut lo = LoopObject::new(1, "LOOP-1", 62).unwrap();
400        lo.write_property(
401            PropertyIdentifier::SETPOINT,
402            None,
403            PropertyValue::Real(72.0),
404            None,
405        )
406        .unwrap();
407        lo.write_property(
408            PropertyIdentifier::PROPORTIONAL_CONSTANT,
409            None,
410            PropertyValue::Real(2.5),
411            None,
412        )
413        .unwrap();
414        lo.write_property(
415            PropertyIdentifier::INTEGRAL_CONSTANT,
416            None,
417            PropertyValue::Real(0.1),
418            None,
419        )
420        .unwrap();
421        lo.write_property(
422            PropertyIdentifier::DERIVATIVE_CONSTANT,
423            None,
424            PropertyValue::Real(0.05),
425            None,
426        )
427        .unwrap();
428
429        assert_eq!(
430            lo.read_property(PropertyIdentifier::SETPOINT, None)
431                .unwrap(),
432            PropertyValue::Real(72.0)
433        );
434        assert_eq!(
435            lo.read_property(PropertyIdentifier::PROPORTIONAL_CONSTANT, None)
436                .unwrap(),
437            PropertyValue::Real(2.5)
438        );
439        assert_eq!(
440            lo.read_property(PropertyIdentifier::INTEGRAL_CONSTANT, None)
441                .unwrap(),
442            PropertyValue::Real(0.1)
443        );
444        assert_eq!(
445            lo.read_property(PropertyIdentifier::DERIVATIVE_CONSTANT, None)
446                .unwrap(),
447            PropertyValue::Real(0.05)
448        );
449    }
450
451    #[test]
452    fn loop_set_present_value() {
453        let mut lo = LoopObject::new(1, "LOOP-1", 62).unwrap();
454        lo.set_present_value(55.0);
455        assert_eq!(
456            lo.read_property(PropertyIdentifier::PRESENT_VALUE, None)
457                .unwrap(),
458            PropertyValue::Real(55.0)
459        );
460    }
461
462    #[test]
463    fn loop_read_object_type() {
464        let lo = LoopObject::new(1, "LOOP-1", 62).unwrap();
465        let val = lo
466            .read_property(PropertyIdentifier::OBJECT_TYPE, None)
467            .unwrap();
468        assert_eq!(val, PropertyValue::Enumerated(ObjectType::LOOP.to_raw()));
469    }
470
471    #[test]
472    fn loop_write_wrong_type_rejected() {
473        let mut lo = LoopObject::new(1, "LOOP-1", 62).unwrap();
474        let result = lo.write_property(
475            PropertyIdentifier::SETPOINT,
476            None,
477            PropertyValue::Unsigned(72),
478            None,
479        );
480        assert!(result.is_err());
481    }
482
483    // --- Property reference tests ---
484
485    #[test]
486    fn loop_references_default_to_null() {
487        let lo = LoopObject::new(1, "LOOP-1", 62).unwrap();
488        assert_eq!(
489            lo.read_property(PropertyIdentifier::CONTROLLED_VARIABLE_REFERENCE, None)
490                .unwrap(),
491            PropertyValue::Null
492        );
493        assert_eq!(
494            lo.read_property(PropertyIdentifier::MANIPULATED_VARIABLE_REFERENCE, None)
495                .unwrap(),
496            PropertyValue::Null
497        );
498        assert_eq!(
499            lo.read_property(PropertyIdentifier::SETPOINT_REFERENCE, None)
500                .unwrap(),
501            PropertyValue::Null
502        );
503    }
504
505    #[test]
506    fn loop_set_references_read_back() {
507        let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 5).unwrap();
508        let prop_raw = PropertyIdentifier::PRESENT_VALUE.to_raw();
509        let expect = PropertyValue::List(vec![
510            PropertyValue::ObjectIdentifier(oid),
511            PropertyValue::Enumerated(prop_raw),
512        ]);
513        let mut lo = LoopObject::new(1, "LOOP-1", 62).unwrap();
514        lo.set_controlled_variable_reference(BACnetObjectPropertyReference::new(oid, prop_raw));
515        lo.set_manipulated_variable_reference(BACnetObjectPropertyReference::new(oid, prop_raw));
516        lo.set_setpoint_reference(BACnetObjectPropertyReference::new(oid, prop_raw));
517        for property in [
518            PropertyIdentifier::CONTROLLED_VARIABLE_REFERENCE,
519            PropertyIdentifier::MANIPULATED_VARIABLE_REFERENCE,
520            PropertyIdentifier::SETPOINT_REFERENCE,
521        ] {
522            assert_eq!(lo.read_property(property, None).unwrap(), expect);
523        }
524    }
525
526    #[test]
527    fn loop_references_in_property_list() {
528        let lo = LoopObject::new(1, "LOOP-1", 62).unwrap();
529        let list = lo.property_list();
530        assert!(list.contains(&PropertyIdentifier::CONTROLLED_VARIABLE_REFERENCE));
531        assert!(list.contains(&PropertyIdentifier::MANIPULATED_VARIABLE_REFERENCE));
532        assert!(list.contains(&PropertyIdentifier::SETPOINT_REFERENCE));
533    }
534
535    #[test]
536    fn loop_write_reference_via_write_property() {
537        let mut lo = LoopObject::new(1, "LOOP-1", 62).unwrap();
538        let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 7).unwrap();
539        let prop_raw = PropertyIdentifier::PRESENT_VALUE.to_raw();
540
541        lo.write_property(
542            PropertyIdentifier::CONTROLLED_VARIABLE_REFERENCE,
543            None,
544            PropertyValue::List(vec![
545                PropertyValue::ObjectIdentifier(oid),
546                PropertyValue::Enumerated(prop_raw),
547            ]),
548            None,
549        )
550        .unwrap();
551
552        assert_eq!(
553            lo.read_property(PropertyIdentifier::CONTROLLED_VARIABLE_REFERENCE, None)
554                .unwrap(),
555            PropertyValue::List(vec![
556                PropertyValue::ObjectIdentifier(oid),
557                PropertyValue::Enumerated(prop_raw),
558            ])
559        );
560    }
561
562    #[test]
563    fn loop_write_null_clears_reference() {
564        let mut lo = LoopObject::new(1, "LOOP-1", 62).unwrap();
565        let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap();
566        lo.set_controlled_variable_reference(BACnetObjectPropertyReference::new(
567            oid,
568            PropertyIdentifier::PRESENT_VALUE.to_raw(),
569        ));
570
571        // Verify it is set
572        assert_ne!(
573            lo.read_property(PropertyIdentifier::CONTROLLED_VARIABLE_REFERENCE, None)
574                .unwrap(),
575            PropertyValue::Null
576        );
577
578        // Write Null to clear
579        lo.write_property(
580            PropertyIdentifier::CONTROLLED_VARIABLE_REFERENCE,
581            None,
582            PropertyValue::Null,
583            None,
584        )
585        .unwrap();
586
587        assert_eq!(
588            lo.read_property(PropertyIdentifier::CONTROLLED_VARIABLE_REFERENCE, None)
589                .unwrap(),
590            PropertyValue::Null
591        );
592    }
593
594    #[test]
595    fn loop_write_reference_wrong_type_rejected() {
596        let mut lo = LoopObject::new(1, "LOOP-1", 62).unwrap();
597        let result = lo.write_property(
598            PropertyIdentifier::CONTROLLED_VARIABLE_REFERENCE,
599            None,
600            PropertyValue::Unsigned(42),
601            None,
602        );
603        assert!(result.is_err());
604    }
605
606    // --- #182: framed (context-tagged) reference writes ---
607
608    fn framed_reference(r: &BACnetObjectPropertyReference) -> PropertyValue {
609        let mut buf = bytes::BytesMut::new();
610        bacnet_encoding::constructed::encode_object_property_reference(&mut buf, r);
611        PropertyValue::ApplicationData(buf.to_vec())
612    }
613
614    #[test]
615    fn loop_write_framed_indexed_reference_reads_back_with_index() {
616        let mut lo = LoopObject::new(1, "LOOP-1", 62).unwrap();
617        let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 7).unwrap();
618        let r = BACnetObjectPropertyReference::new_indexed(
619            oid,
620            PropertyIdentifier::PRESENT_VALUE.to_raw(),
621            3,
622        );
623        lo.write_property(
624            PropertyIdentifier::CONTROLLED_VARIABLE_REFERENCE,
625            None,
626            framed_reference(&r),
627            None,
628        )
629        .unwrap();
630        // The optional array-index member is carried through to the read form.
631        assert_eq!(
632            lo.read_property(PropertyIdentifier::CONTROLLED_VARIABLE_REFERENCE, None)
633                .unwrap(),
634            PropertyValue::List(vec![
635                PropertyValue::ObjectIdentifier(oid),
636                PropertyValue::Enumerated(PropertyIdentifier::PRESENT_VALUE.to_raw()),
637                PropertyValue::Unsigned(3),
638            ])
639        );
640    }
641
642    #[test]
643    fn loop_write_setpoint_reference_accepts_bacnetsetpointreference_frame() {
644        let mut lo = LoopObject::new(1, "LOOP-1", 62).unwrap();
645        let oid = ObjectIdentifier::new(ObjectType::ANALOG_VALUE, 10).unwrap();
646        let r = BACnetObjectPropertyReference::new(oid, PropertyIdentifier::PRESENT_VALUE.to_raw());
647        let mut buf = bytes::BytesMut::new();
648        bacnet_encoding::constructed::encode_setpoint_reference(&mut buf, &r);
649        lo.write_property(
650            PropertyIdentifier::SETPOINT_REFERENCE,
651            None,
652            PropertyValue::ApplicationData(buf.to_vec()),
653            None,
654        )
655        .unwrap();
656        assert_eq!(
657            lo.read_property(PropertyIdentifier::SETPOINT_REFERENCE, None)
658                .unwrap(),
659            PropertyValue::List(vec![
660                PropertyValue::ObjectIdentifier(oid),
661                PropertyValue::Enumerated(PropertyIdentifier::PRESENT_VALUE.to_raw()),
662            ])
663        );
664    }
665
666    #[test]
667    fn loop_write_device_qualified_reference_rejected_and_preserves() {
668        let mut lo = LoopObject::new(1, "LOOP-1", 62).unwrap();
669        let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 7).unwrap();
670        let prop_raw = PropertyIdentifier::PRESENT_VALUE.to_raw();
671        lo.set_controlled_variable_reference(BACnetObjectPropertyReference::new(oid, prop_raw));
672        // [0] oid / [1] prop / [3] device: device qualification is not part of
673        // the Loop reference production — INVALID_DATA_ENCODING, no change.
674        let mut buf = bytes::BytesMut::new();
675        bacnet_encoding::constructed::encode_object_property_reference(
676            &mut buf,
677            &BACnetObjectPropertyReference::new(oid, prop_raw),
678        );
679        bacnet_encoding::primitives::encode_ctx_object_id(
680            &mut buf,
681            3,
682            &ObjectIdentifier::new(ObjectType::DEVICE, 77).unwrap(),
683        );
684        let err = lo
685            .write_property(
686                PropertyIdentifier::CONTROLLED_VARIABLE_REFERENCE,
687                None,
688                PropertyValue::ApplicationData(buf.to_vec()),
689                None,
690            )
691            .unwrap_err();
692        match err {
693            Error::Protocol { class, code } => {
694                assert_eq!(class, ErrorClass::PROPERTY.to_raw() as u32);
695                assert_eq!(code, ErrorCode::INVALID_DATA_ENCODING.to_raw() as u32);
696            }
697            other => panic!("expected PROPERTY/INVALID_DATA_ENCODING, got {other:?}"),
698        }
699        assert_eq!(
700            lo.read_property(PropertyIdentifier::CONTROLLED_VARIABLE_REFERENCE, None)
701                .unwrap(),
702            PropertyValue::List(vec![
703                PropertyValue::ObjectIdentifier(oid),
704                PropertyValue::Enumerated(prop_raw),
705            ])
706        );
707    }
708
709    #[test]
710    fn loop_write_empty_setpoint_frame_clears_reference() {
711        let mut lo = LoopObject::new(1, "LOOP-1", 62).unwrap();
712        let oid = ObjectIdentifier::new(ObjectType::ANALOG_VALUE, 10).unwrap();
713        let r = BACnetObjectPropertyReference::new(oid, PropertyIdentifier::PRESENT_VALUE.to_raw());
714        let mut buf = bytes::BytesMut::new();
715        bacnet_encoding::constructed::encode_setpoint_reference(&mut buf, &r);
716        lo.write_property(
717            PropertyIdentifier::SETPOINT_REFERENCE,
718            None,
719            PropertyValue::ApplicationData(buf.to_vec()),
720            None,
721        )
722        .unwrap();
723        assert_ne!(
724            lo.read_property(PropertyIdentifier::SETPOINT_REFERENCE, None)
725                .unwrap(),
726            PropertyValue::Null
727        );
728
729        // 0x0E 0x0F: BACnetSetpointReference with its OPTIONAL member absent —
730        // Clause 12.17 defines the absence as "fixed setpoint", so a
731        // conformant peer clearing the reference this way must be accepted,
732        // exactly like a Null write (pinned over the wire in the server's
733        // `reference_writes` tests).
734        lo.write_property(
735            PropertyIdentifier::SETPOINT_REFERENCE,
736            None,
737            PropertyValue::ApplicationData(vec![0x0E, 0x0F]),
738            None,
739        )
740        .unwrap();
741        assert_eq!(
742            lo.read_property(PropertyIdentifier::SETPOINT_REFERENCE, None)
743                .unwrap(),
744            PropertyValue::Null
745        );
746    }
747
748    #[test]
749    fn loop_write_local_list_with_index_still_accepted() {
750        let mut lo = LoopObject::new(1, "LOOP-1", 62).unwrap();
751        let oid = ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 7).unwrap();
752        let prop_raw = PropertyIdentifier::PRESENT_VALUE.to_raw();
753        lo.write_property(
754            PropertyIdentifier::SETPOINT_REFERENCE,
755            None,
756            PropertyValue::List(vec![
757                PropertyValue::ObjectIdentifier(oid),
758                PropertyValue::Enumerated(prop_raw),
759                PropertyValue::Unsigned(9),
760            ]),
761            None,
762        )
763        .unwrap();
764        assert_eq!(
765            lo.read_property(PropertyIdentifier::SETPOINT_REFERENCE, None)
766                .unwrap(),
767            PropertyValue::List(vec![
768                PropertyValue::ObjectIdentifier(oid),
769                PropertyValue::Enumerated(prop_raw),
770                PropertyValue::Unsigned(9),
771            ])
772        );
773    }
774}