knx-rs-core 0.3.1

Platform-independent KNX protocol types, CEMI frames, and DPT conversions
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
// SPDX-License-Identifier: GPL-3.0-only
// Copyright (C) 2026 Fabian Schmieder

//! KNX Datapoint Type (DPT) framework.
//!
//! The [`DptValue`] enum is the single type for all KNX datapoint values.
//! Each variant matches the natural type for its DPT group:
//!
//! | Variant | DPT groups |
//! |---------|------------|
//! | `Bool` | 1 |
//! | `UInt` | 2, 3, 4, 5 (raw), 7, 12, 15, 17, 18, 26, 232, 238 |
//! | `Int` | 6, 8, 13, 27 |
//! | `Float` | 5.001/5.003 (scaled), 9, 14 |
//! | `Int64` | 29 |
//! | `Text` | 16, 28 |
//! | `Bytes` | 10, 11, 19, 217, 219, 221, 225, 231, 234, 235, 239 |

mod convert;

use alloc::string::String;
use alloc::vec::Vec;
use core::fmt;

/// A KNX Datapoint Type identifier (main group / sub group / index).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Dpt {
    /// Main group number.
    pub main: u16,
    /// Sub group number.
    pub sub: u16,
    /// Index (usually 0).
    pub index: u16,
}

impl Dpt {
    /// Create a new DPT identifier.
    pub const fn new(main: u16, sub: u16) -> Self {
        Self {
            main,
            sub,
            index: 0,
        }
    }

    /// Create a new DPT identifier with index.
    pub const fn with_index(main: u16, sub: u16, index: u16) -> Self {
        Self { main, sub, index }
    }

    /// Wire data length in bytes for this DPT's main group.
    ///
    /// Falls back to `1` for variable-length and unrecognised main groups; use
    /// [`Dpt::wire_len`] to distinguish a genuine 1-byte DPT from that fallback.
    pub const fn data_length(self) -> u8 {
        match self.main {
            7 | 8 | 9 | 22 | 207 | 217 | 234 | 237 | 239 | 244 | 246 => 2,
            10 | 11 | 30 | 206 | 225 | 232 | 240 | 250 | 254 => 3,
            12 | 13 | 14 | 15 | 27 | 231 | 241 | 251 => 4,
            252 => 5,
            219 | 221 | 222 | 229 | 235 | 242 | 245 | 249 => 6,
            19 | 29 | 230 | 255 | 275 => 8,
            16 => 14,
            285 => 16,
            _ => 1,
        }
    }

    /// Whether this DPT's main group has a variable wire length.
    ///
    /// DPT 28 (Unicode string) is null-terminated and has no fixed size.
    #[must_use]
    pub const fn is_variable_length(self) -> bool {
        matches!(self.main, 28)
    }

    /// The fixed wire length in bytes, or `None` for variable-length DPTs.
    ///
    /// Unlike [`Dpt::data_length`], this is the single source of truth for
    /// "does this DPT have a fixed size", so callers (e.g. group-object buffer
    /// sizing) can treat variable-length DPTs explicitly instead of silently
    /// allocating a 1-byte buffer.
    #[must_use]
    pub const fn wire_len(self) -> Option<u8> {
        if self.is_variable_length() {
            None
        } else {
            Some(self.data_length())
        }
    }
}

impl fmt::Display for Dpt {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.index == 0 {
            write!(f, "{}.{:03}", self.main, self.sub)
        } else {
            write!(f, "{}.{:03}.{}", self.main, self.sub, self.index)
        }
    }
}

/// A typed KNX datapoint value.
///
/// Each variant matches the natural type for its DPT group.
/// Use [`From`] impls for ergonomic construction.
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum DptValue {
    /// Boolean (DPT 1).
    Bool(bool),
    /// Unsigned integer (DPT 2, 3, 4, 5, 7, 12, 15, 17, 18, 26, 232, 238).
    UInt(u32),
    /// Signed integer (DPT 6, 8, 13, 27).
    Int(i32),
    /// Floating point (DPT 9, 14).
    Float(f64),
    /// Signed 64-bit integer (DPT 29).
    Int64(i64),
    /// String (DPT 16, 28).
    Text(String),
    /// Raw bytes (DPT 10, 11, 19, 217, 219, 221, 225, 231, 234, 235, 239).
    Bytes(Vec<u8>),
}

impl fmt::Display for DptValue {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Bool(v) => write!(f, "{v}"),
            Self::UInt(v) => write!(f, "{v}"),
            Self::Int(v) => write!(f, "{v}"),
            Self::Float(v) => write!(f, "{v}"),
            Self::Int64(v) => write!(f, "{v}"),
            Self::Text(s) => f.write_str(s),
            Self::Bytes(b) => {
                for (i, byte) in b.iter().enumerate() {
                    if i > 0 {
                        f.write_str(" ")?;
                    }
                    write!(f, "{byte:02X}")?;
                }
                Ok(())
            }
        }
    }
}

impl DptValue {
    /// Get as bool. Returns `None` if not `Bool`.
    pub const fn as_bool(&self) -> Option<bool> {
        match self {
            Self::Bool(v) => Some(*v),
            _ => None,
        }
    }

    /// Get as u32. Returns `None` if not `UInt`.
    pub const fn as_u32(&self) -> Option<u32> {
        match self {
            Self::UInt(v) => Some(*v),
            _ => None,
        }
    }

    /// Get as i32. Returns `None` if not `Int`.
    pub const fn as_i32(&self) -> Option<i32> {
        match self {
            Self::Int(v) => Some(*v),
            _ => None,
        }
    }

    /// Get as f64. Converts from any numeric variant.
    ///
    /// Note: `Int64` → `f64` may lose precision for values > 2^53.
    #[expect(
        clippy::cast_precision_loss,
        reason = "i64→f64 precision loss is inherent and documented"
    )]
    pub const fn as_f64(&self) -> Option<f64> {
        match self {
            Self::Float(v) => Some(*v),
            Self::Bool(v) => Some(if *v { 1.0 } else { 0.0 }),
            Self::UInt(v) => Some(*v as f64), // lossless: f64 covers all u32
            Self::Int(v) => Some(*v as f64),  // lossless: f64 covers all i32
            Self::Int64(v) => Some(*v as f64), // lossy for |v| > 2^53
            _ => None,
        }
    }

    /// Get as i64. Returns `None` if not `Int64`.
    pub const fn as_i64(&self) -> Option<i64> {
        match self {
            Self::Int64(v) => Some(*v),
            _ => None,
        }
    }

    /// Get as string slice. Returns `None` if not `Text`.
    pub fn as_str(&self) -> Option<&str> {
        match self {
            Self::Text(s) => Some(s),
            _ => None,
        }
    }

    /// Get as byte slice. Returns `None` if not `Bytes`.
    pub fn as_bytes(&self) -> Option<&[u8]> {
        match self {
            Self::Bytes(b) => Some(b),
            _ => None,
        }
    }
}

// ── From impls ────────────────────────────────────────────────

impl From<bool> for DptValue {
    fn from(v: bool) -> Self {
        Self::Bool(v)
    }
}

impl From<u8> for DptValue {
    fn from(v: u8) -> Self {
        Self::UInt(u32::from(v))
    }
}

impl From<u16> for DptValue {
    fn from(v: u16) -> Self {
        Self::UInt(u32::from(v))
    }
}

impl From<u32> for DptValue {
    fn from(v: u32) -> Self {
        Self::UInt(v)
    }
}

impl From<i8> for DptValue {
    fn from(v: i8) -> Self {
        Self::Int(i32::from(v))
    }
}

impl From<i16> for DptValue {
    fn from(v: i16) -> Self {
        Self::Int(i32::from(v))
    }
}

impl From<i32> for DptValue {
    fn from(v: i32) -> Self {
        Self::Int(v)
    }
}

impl From<i64> for DptValue {
    fn from(v: i64) -> Self {
        Self::Int64(v)
    }
}

impl From<f32> for DptValue {
    fn from(v: f32) -> Self {
        Self::Float(f64::from(v))
    }
}

impl From<f64> for DptValue {
    fn from(v: f64) -> Self {
        Self::Float(v)
    }
}

impl From<String> for DptValue {
    fn from(s: String) -> Self {
        Self::Text(s)
    }
}

impl From<&str> for DptValue {
    fn from(s: &str) -> Self {
        Self::Text(String::from(s))
    }
}

impl From<Vec<u8>> for DptValue {
    fn from(b: Vec<u8>) -> Self {
        Self::Bytes(b)
    }
}

/// Error returned when DPT encoding or decoding fails.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DptError {
    /// The payload is too short for the requested DPT.
    PayloadTooShort,
    /// The DPT main group is not supported.
    UnsupportedDpt(Dpt),
    /// The value is out of range for the requested DPT.
    OutOfRange {
        /// Human-readable context (e.g. "expected 0..=255, got 300").
        context: &'static str,
    },
    /// Wrong value type for the DPT (e.g. Bool for a float DPT).
    TypeMismatch,
    /// No DPT configured on the group object.
    NoDpt,
}

impl DptError {
    /// Create an `OutOfRange` error with context.
    pub const fn out_of_range(context: &'static str) -> Self {
        Self::OutOfRange { context }
    }
}

impl fmt::Display for DptError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::PayloadTooShort => f.write_str("payload too short for DPT"),
            Self::UnsupportedDpt(dpt) => write!(f, "unsupported DPT: {dpt}"),
            Self::OutOfRange { context } => write!(f, "value out of range: {context}"),
            Self::TypeMismatch => f.write_str("wrong value type for DPT"),
            Self::NoDpt => f.write_str("no DPT configured on group object"),
        }
    }
}

impl core::error::Error for DptError {}

/// Decode a KNX bus payload into a [`DptValue`].
///
/// # Errors
///
/// Returns [`DptError`] if the payload is too short or the DPT is unsupported.
pub fn decode(dpt: Dpt, payload: &[u8]) -> Result<DptValue, DptError> {
    convert::decode(dpt, payload)
}

/// Encode a [`DptValue`] into a KNX bus payload.
///
/// # Errors
///
/// Returns [`DptError`] if the value type doesn't match the DPT or is out of range.
pub fn encode(dpt: Dpt, value: &DptValue) -> Result<Vec<u8>, DptError> {
    convert::encode(dpt, value)
}

// ── Well-known DPT constants ──────────────────────────────────

/// DPT 1.001 — Switch (bool).
pub const DPT_SWITCH: Dpt = Dpt::new(1, 1);
/// DPT 1.002 — Bool.
pub const DPT_BOOL: Dpt = Dpt::new(1, 2);
/// DPT 4.001 — ASCII character.
pub const DPT_CHAR_ASCII: Dpt = Dpt::new(4, 1);
/// DPT 5.001 — Scaling (0–100%).
pub const DPT_SCALING: Dpt = Dpt::new(5, 1);
/// DPT 5.003 — Angle (0–360°).
pub const DPT_ANGLE: Dpt = Dpt::new(5, 3);
/// DPT 5.010 — Unsigned count (0–255).
pub const DPT_VALUE_1_UCOUNT: Dpt = Dpt::new(5, 10);
/// DPT 7.001 — Unsigned 16-bit count.
pub const DPT_VALUE_2_UCOUNT: Dpt = Dpt::new(7, 1);
/// DPT 8.001 — Signed 16-bit count.
pub const DPT_VALUE_2_COUNT: Dpt = Dpt::new(8, 1);
/// DPT 9.001 — Temperature (°C).
pub const DPT_VALUE_TEMP: Dpt = Dpt::new(9, 1);
/// DPT 9.004 — Lux.
pub const DPT_VALUE_LUX: Dpt = Dpt::new(9, 4);
/// DPT 10.001 — Time of day.
pub const DPT_TIMEOFDAY: Dpt = Dpt::with_index(10, 1, 1);
/// DPT 11.001 — Date.
pub const DPT_DATE: Dpt = Dpt::new(11, 1);
/// DPT 12.001 — Unsigned 32-bit count.
pub const DPT_VALUE_4_UCOUNT: Dpt = Dpt::new(12, 1);
/// DPT 13.001 — Signed 32-bit count.
pub const DPT_VALUE_4_COUNT: Dpt = Dpt::new(13, 1);
/// DPT 14.056 — Power (W).
pub const DPT_VALUE_POWER: Dpt = Dpt::new(14, 56);
/// DPT 15.000 — Access data.
pub const DPT_ACCESS_DATA: Dpt = Dpt::new(15, 0);
/// DPT 16.000 — ASCII string (14 bytes).
pub const DPT_STRING_ASCII: Dpt = Dpt::new(16, 0);
/// DPT 16.001 — ISO 8859-1 string (14 bytes).
pub const DPT_STRING_8859_1: Dpt = Dpt::new(16, 1);
/// DPT 17.001 — Scene number (0–63).
pub const DPT_SCENE_NUMBER: Dpt = Dpt::new(17, 1);
/// DPT 18.001 — Scene control.
pub const DPT_SCENE_CONTROL: Dpt = Dpt::new(18, 1);
/// DPT 19.001 — Date and time.
pub const DPT_DATETIME: Dpt = Dpt::new(19, 1);
/// DPT 29.010 — Active energy (Wh).
pub const DPT_ACTIVE_ENERGY_V64: Dpt = Dpt::new(29, 10);
/// DPT 232.600 — RGB colour.
pub const DPT_COLOUR_RGB: Dpt = Dpt::new(232, 600);
/// DPT 251.600 — RGBW colour.
pub const DPT_COLOUR_RGBW: Dpt = Dpt::new(251, 600);

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    use super::*;

    #[test]
    fn encode_length_matches_wire_len() {
        // Guards against drift between Dpt::wire_len and the codec dispatch:
        // for every fixed-length DPT, encode() must produce wire_len() bytes.
        let cases: &[(Dpt, DptValue)] = &[
            (DPT_SWITCH, DptValue::Bool(true)),
            (DPT_SCALING, DptValue::Float(50.0)),
            (DPT_VALUE_1_UCOUNT, DptValue::UInt(42)),
            (DPT_VALUE_2_UCOUNT, DptValue::UInt(1000)),
            (DPT_VALUE_2_COUNT, DptValue::Int(-1000)),
            (DPT_VALUE_TEMP, DptValue::Float(21.5)),
            (DPT_VALUE_4_UCOUNT, DptValue::UInt(100_000)),
            (DPT_VALUE_4_COUNT, DptValue::Int(-5)),
            (DPT_VALUE_POWER, DptValue::Float(1234.0)),
            (DPT_SCENE_NUMBER, DptValue::UInt(5)),
            (DPT_STRING_ASCII, DptValue::Text("hi".into())),
        ];
        for (dpt, value) in cases {
            let expected = dpt.wire_len().expect("fixed-length DPT");
            let encoded = encode(*dpt, value).expect("encode");
            assert_eq!(
                encoded.len(),
                usize::from(expected),
                "wire_len/codec length mismatch for DPT {dpt}"
            );
        }
    }

    #[test]
    fn variable_length_dpt_has_no_wire_len() {
        let dpt28 = Dpt::new(28, 1);
        assert!(dpt28.is_variable_length());
        assert_eq!(dpt28.wire_len(), None);
    }
}