Skip to main content

knx_rs_ip/
ops.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 Fabian Schmieder
3
4//! Application-level group operations for KNX connections.
5//!
6//! The [`GroupOps`] extension trait adds high-level group communication
7//! methods to any [`KnxConnection`]. Import it to use `group_write`,
8//! `group_read`, and DPT-aware variants.
9//!
10//! ```rust,no_run
11//! use knx_rs_ip::{KnxConnection, connect, parse_url};
12//! use knx_rs_ip::ops::GroupOps;
13//! use knx_rs_core::address::GroupAddress;
14//! use knx_rs_core::dpt::{DptValue, DPT_SWITCH, DPT_VALUE_TEMP};
15//!
16//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
17//! let mut conn = connect(parse_url("udp://192.168.1.50:3671")?).await?;
18//! let ga = "1/0/1".parse()?;
19//!
20//! conn.group_write(ga, &[0x01]).await?;
21//! conn.group_write_value(ga, DPT_SWITCH, &true.into()).await?;
22//! conn.group_write_value(ga, DPT_VALUE_TEMP, &21.5.into()).await?;
23//! conn.group_read(ga).await?;
24//! # Ok(())
25//! # }
26//! ```
27
28use knx_rs_core::address::{DestinationAddress, GroupAddress, IndividualAddress};
29use knx_rs_core::cemi::CemiFrame;
30use knx_rs_core::dpt::{self, Dpt, DptValue};
31use knx_rs_core::message::MessageCode;
32use knx_rs_core::types::Priority;
33
34use crate::error::KnxIpError;
35use crate::{KnxConnection, KnxFuture};
36
37/// Extension trait for group-level KNX operations.
38///
39/// Provides high-level methods on top of any [`KnxConnection`].
40/// All APDU encoding is handled internally.
41pub trait GroupOps: KnxConnection {
42    /// Write a raw value to a group address.
43    ///
44    /// # Errors
45    ///
46    /// Returns [`KnxIpError`] if the frame could not be sent.
47    fn group_write(&self, ga: GroupAddress, data: &[u8]) -> KnxFuture<'_, Result<(), KnxIpError>> {
48        let frame = match build_group_write(ga, data) {
49            Ok(frame) => frame,
50            Err(err) => return Box::pin(core::future::ready(Err(err))),
51        };
52        self.send(frame)
53    }
54
55    /// Write a DPT-encoded [`DptValue`] to a group address.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`KnxIpError`] if encoding fails or the frame could not be sent.
60    fn group_write_value(
61        &self,
62        ga: GroupAddress,
63        dpt: Dpt,
64        value: &DptValue,
65    ) -> KnxFuture<'_, Result<(), KnxIpError>> {
66        let encoded = match dpt::encode(dpt, value) {
67            Ok(encoded) => encoded,
68            Err(err) => {
69                return Box::pin(core::future::ready(Err(KnxIpError::Protocol(
70                    err.to_string(),
71                ))));
72            }
73        };
74        let frame = match build_group_write(ga, &encoded) {
75            Ok(frame) => frame,
76            Err(err) => return Box::pin(core::future::ready(Err(err))),
77        };
78        self.send(frame)
79    }
80
81    /// Send a group read request.
82    ///
83    /// The response (if any) will arrive as a normal received frame.
84    ///
85    /// # Errors
86    ///
87    /// Returns [`KnxIpError`] if the frame could not be sent.
88    fn group_read(&self, ga: GroupAddress) -> KnxFuture<'_, Result<(), KnxIpError>> {
89        let frame = match build_group_read(ga) {
90            Ok(frame) => frame,
91            Err(err) => return Box::pin(core::future::ready(Err(err))),
92        };
93        self.send(frame)
94    }
95
96    /// Send a group value response.
97    ///
98    /// # Errors
99    ///
100    /// Returns [`KnxIpError`] if the frame could not be sent.
101    fn group_respond(
102        &self,
103        ga: GroupAddress,
104        data: &[u8],
105    ) -> KnxFuture<'_, Result<(), KnxIpError>> {
106        let frame = match build_group_response(ga, data) {
107            Ok(frame) => frame,
108            Err(err) => return Box::pin(core::future::ready(Err(err))),
109        };
110        self.send(frame)
111    }
112}
113
114// Blanket implementation for all KnxConnection types.
115impl<T: KnxConnection> GroupOps for T {}
116
117// ── Frame builders (internal) ─────────────────────────────────
118
119fn build_group_write(ga: GroupAddress, data: &[u8]) -> Result<CemiFrame, KnxIpError> {
120    let mut payload = Vec::with_capacity(2 + data.len());
121    payload.push(0x00); // TPCI: unnumbered data
122    if data.len() == 1 && data[0] <= 0x3F {
123        payload.push(0x80 | (data[0] & 0x3F)); // short GroupValueWrite
124    } else {
125        payload.push(0x80); // GroupValueWrite APCI
126        payload.extend_from_slice(data);
127    }
128    CemiFrame::try_new_l_data(
129        MessageCode::LDataReq,
130        IndividualAddress::from_raw(0x0000), // filled by gateway
131        DestinationAddress::Group(ga),
132        Priority::Low,
133        &payload,
134    )
135    .map_err(|e| KnxIpError::Protocol(e.to_string()))
136}
137
138fn build_group_read(ga: GroupAddress) -> Result<CemiFrame, KnxIpError> {
139    CemiFrame::try_new_l_data(
140        MessageCode::LDataReq,
141        IndividualAddress::from_raw(0x0000),
142        DestinationAddress::Group(ga),
143        Priority::Low,
144        &[0x00, 0x00], // GroupValueRead
145    )
146    .map_err(|e| KnxIpError::Protocol(e.to_string()))
147}
148
149fn build_group_response(ga: GroupAddress, data: &[u8]) -> Result<CemiFrame, KnxIpError> {
150    let mut payload = Vec::with_capacity(2 + data.len());
151    payload.push(0x00);
152    if data.len() == 1 && data[0] <= 0x3F {
153        payload.push(0x40 | (data[0] & 0x3F)); // short GroupValueResponse
154    } else {
155        payload.push(0x40); // GroupValueResponse APCI
156        payload.extend_from_slice(data);
157    }
158    CemiFrame::try_new_l_data(
159        MessageCode::LDataReq,
160        IndividualAddress::from_raw(0x0000),
161        DestinationAddress::Group(ga),
162        Priority::Low,
163        &payload,
164    )
165    .map_err(|e| KnxIpError::Protocol(e.to_string()))
166}
167
168#[cfg(test)]
169#[allow(clippy::unwrap_used)]
170mod tests {
171    use super::*;
172    use knx_rs_core::address::GroupAddress;
173    use knx_rs_core::dpt::{DPT_SWITCH, DPT_VALUE_TEMP};
174
175    #[test]
176    fn build_group_write_short() {
177        let frame = build_group_write(GroupAddress::from_raw(0x0801), &[0x01]).unwrap();
178        assert_eq!(frame.destination_address_raw(), 0x0801);
179        let payload = frame.payload();
180        assert_eq!(payload[0], 0x00); // TPCI
181        assert_eq!(payload[1], 0x81); // GroupValueWrite | 0x01
182    }
183
184    #[test]
185    fn build_group_write_long() {
186        let data = [0x0C, 0x34]; // DPT9 temperature
187        let frame = build_group_write(GroupAddress::from_raw(0x0801), &data).unwrap();
188        let payload = frame.payload();
189        assert_eq!(payload[0], 0x00);
190        assert_eq!(payload[1], 0x80); // GroupValueWrite
191        assert_eq!(&payload[2..], &[0x0C, 0x34]);
192    }
193
194    #[test]
195    fn build_group_read_frame() {
196        let frame = build_group_read(GroupAddress::from_raw(0x0801)).unwrap();
197        let payload = frame.payload();
198        assert_eq!(payload, &[0x00, 0x00]);
199    }
200
201    #[test]
202    fn build_group_response_short() {
203        let frame = build_group_response(GroupAddress::from_raw(0x0801), &[0x01]).unwrap();
204        let payload = frame.payload();
205        assert_eq!(payload[1], 0x41); // GroupValueResponse | 0x01
206    }
207
208    #[test]
209    fn dpt_encoding_in_write() {
210        let encoded = dpt::encode(DPT_SWITCH, &DptValue::Bool(true)).unwrap();
211        let frame = build_group_write(GroupAddress::from_raw(0x0802), &encoded).unwrap();
212        let payload = frame.payload();
213        assert_eq!(payload[1], 0x81); // GroupValueWrite | 1
214
215        let encoded = dpt::encode(DPT_VALUE_TEMP, &DptValue::Float(21.5)).unwrap();
216        let frame = build_group_write(GroupAddress::from_raw(0x0801), &encoded).unwrap();
217        assert_eq!(frame.payload().len(), 4); // TPCI + APCI + 2 bytes DPT9
218    }
219}