Skip to content

Commit 3963bc6

Browse files
farmioclaude
andcommitted
refactor: let each control field part serialize itself
`CEMIFlags.to_knx()` took a `frame_type` argument that shadowed its own `frame_type` field, which reads as if the field were the default. Give `CEMIFrameType` and the new `CEMIAddressType` their own `to_knx()` / `from_knx()` placing their single bit, and let the frame compose: self.flags.to_knx() | frame_type.to_knx() | self.address_type.to_knx() `CEMIFlags.to_knx()` now only sets the bits it owns and takes no arguments, so nothing shadows anything. Control fields are handled as one 16 bit value (Ctrl1 << 8 | Ctrl2) to make the composition a plain OR; `to_knx()` returns an int and `from_knx()` takes one, as `TPCI.to_knx()` already does for a bit level field. The Frame Type and Address Type bit masks are gone from the module level constants - each enum places its own bit - which was the only place where the 8/16 bit mixing was actually confusing. `CEMILData.dst_is_group_address` is replaced by `CEMILData.address_type`. `block_0()` and the `SecureData` methods take `address_type: CEMIAddressType` accordingly; the B0 flags octet is literally the Address Type bit plus the Extended Frame Format, so `address_type.to_knx()` is that octet and the manual constant is gone. Byte vectors are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c072f10 commit 3963bc6

9 files changed

Lines changed: 186 additions & 118 deletions

File tree

docs/changelog.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ nav_order: 2
1717
- `confirm_error: bool` - only meaningful in an L_Data.con frame
1818
- `hop_count: int` - replaces the `CEMILData.hops` property, which was removed
1919
- `frame_type: CEMIFrameType` and `frame_format: CEMIFrameFormat` - informational; they hold what was received and are ignored when serializing
20-
The Frame Type of an outgoing frame is derived from its NPDU length and the Address Type from the type of the destination address (also available as `CEMILData.dst_is_group_address`), so neither can disagree with the frame that is put on the wire. The raw bit masks moved to `xknx.cemi.flags` as module level constants.
20+
The Frame Type of an outgoing frame is derived from its NPDU length and the Address Type from the type of the destination address (`CEMILData.address_type`), so neither can disagree with the frame that is put on the wire. `CEMIFrameType` and the new `CEMIAddressType` place their own bit via `to_knx()` / `from_knx()`; `CEMILData` composes the control field as `flags.to_knx() | frame_type.to_knx() | address_type.to_knx()`. Ctrl1 and Ctrl2 are handled as one 16 bit int, so `CEMIFlags.to_knx()` returns an `int` and `CEMIFlags.from_knx()` takes one. The remaining bit masks moved to `xknx.cemi.flags` as module level constants.
2121
- `CEMIFlags` has a compact `__str__` used in `CEMILData.__repr__` - eg. `LOW STANDARD hop_count=6`, listing only the boolean flags that are set. The full dataclass `__repr__` shows every field.
2222
- `CEMILData(flags=...)` is optional now and defaults to `CEMIFlags()` - low priority, hop count 6, no acknowledge request.
23-
- `xknx.secure.data_secure_asdu.block_0()`, `SecureData.init_from_plain_apdu()` and `SecureData.get_plain_apdu()` take `dst_is_group_address: bool` instead of `frame_flags: int`. Only the Address Type bit of Ctrl2 ever reached the CCM input; passing it explicitly removes the mask.
23+
- `xknx.secure.data_secure_asdu.block_0()`, `SecureData.init_from_plain_apdu()` and `SecureData.get_plain_apdu()` take `address_type: CEMIAddressType` instead of `frame_flags: int`. Only the Address Type bit of Ctrl2 ever reached the CCM input, so the B0 flags octet is `address_type.to_knx()` and `B0_AT_FIELD_FLAGS_MASK` is gone.
2424

2525
### Bugfixes
2626

test/cemi_tests/cemi_frame_test.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import pytest
44

55
from xknx.cemi import (
6+
CEMIAddressType,
67
CEMIFlags,
78
CEMIFrame,
9+
CEMIFrameType,
810
CEMILData,
911
CEMIMessageCode,
1012
CEMIMPropReadRequest,
@@ -14,7 +16,6 @@
1416
CEMIPriority,
1517
)
1618
from xknx.cemi.const import CEMIErrorCode
17-
from xknx.cemi.flags import DESTINATION_GROUP_ADDRESS, FRAME_TYPE_STANDARD
1819
from xknx.dpt import DPTArray
1920
from xknx.exceptions import ConversionError, CouldNotParseCEMI, UnsupportedCEMIMessage
2021
from xknx.profile.const import ResourceKNXNETIPPropertyId, ResourceObjectType
@@ -244,7 +245,7 @@ def test_telegram_group_address() -> None:
244245
data=CEMILData.init_from_telegram(_telegram),
245246
)
246247
assert isinstance(frame.data, CEMILData)
247-
assert frame.data.dst_is_group_address
248+
assert frame.data.address_type is CEMIAddressType.GROUP
248249
assert frame.data.flags == CEMIFlags(priority=CEMIPriority.LOW)
249250
# test CEMIFrame.telegram property
250251
assert frame.data.telegram() == _telegram
@@ -258,7 +259,7 @@ def test_telegram_broadcast() -> None:
258259
data=CEMILData.init_from_telegram(_telegram),
259260
)
260261
assert isinstance(frame.data, CEMILData)
261-
assert frame.data.dst_is_group_address
262+
assert frame.data.address_type is CEMIAddressType.GROUP
262263
assert frame.data.flags == CEMIFlags(priority=CEMIPriority.SYSTEM)
263264
assert frame.data.tpci == TDataBroadcast()
264265
# test CEMIFrame.telegram property
@@ -273,7 +274,7 @@ def test_telegram_individual_address() -> None:
273274
data=CEMILData.init_from_telegram(_telegram),
274275
)
275276
assert isinstance(frame.data, CEMILData)
276-
assert not frame.data.dst_is_group_address
277+
assert frame.data.address_type is CEMIAddressType.INDIVIDUAL
277278
assert frame.data.flags == CEMIFlags(priority=CEMIPriority.SYSTEM)
278279
assert not frame.data.flags.acknowledge_request
279280
# test CEMIFrame.telegram property
@@ -315,7 +316,9 @@ def test_frame_type_from_npdu_length(
315316
GroupValueWrite(DPTArray(bytes(apdu_payload_length)))
316317
)
317318
assert raw[6] == expected_npdu_len
318-
assert bool(raw[0] & FRAME_TYPE_STANDARD) is expected_standard_frame
319+
assert (
320+
CEMIFrameType.from_knx(raw[0] << 8) is CEMIFrameType.STANDARD
321+
) is expected_standard_frame
319322

320323

321324
def test_frame_type_follows_payload() -> None:
@@ -326,11 +329,11 @@ def test_frame_type_follows_payload() -> None:
326329
tpci=TDataGroup(),
327330
payload=GroupValueWrite(DPTArray(bytes(15))),
328331
)
329-
assert not cemi_data.to_knx()[0] & FRAME_TYPE_STANDARD
332+
assert CEMIFrameType.from_knx(cemi_data.to_knx()[0] << 8) is CEMIFrameType.EXTENDED
330333

331334
# replacing the payload - as Data Secure does - changes the Frame Type
332335
cemi_data.payload = GroupValueWrite(DPTArray(bytes(1)))
333-
assert cemi_data.to_knx()[0] & FRAME_TYPE_STANDARD
336+
assert CEMIFrameType.from_knx(cemi_data.to_knx()[0] << 8) is CEMIFrameType.STANDARD
334337

335338

336339
def test_npdu_length_exceeded() -> None:
@@ -355,7 +358,7 @@ def test_extended_frame_format_not_supported(eff: int, err_msg: str) -> None:
355358
raw = get_data(
356359
0x29,
357360
0,
358-
(DESTINATION_GROUP_ADDRESS | eff), # Ctrl2; Ctrl1 = extended frame
361+
(CEMIAddressType.GROUP.to_knx() | eff), # Ctrl2; Ctrl1 = extended frame
359362
1,
360363
1,
361364
1,

test/cemi_tests/flags_test.py

Lines changed: 73 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,35 @@
22

33
import pytest
44

5-
from xknx.cemi import CEMIFlags, CEMIFrameFormat, CEMIFrameType, CEMIPriority
5+
from xknx.cemi import (
6+
CEMIAddressType,
7+
CEMIFlags,
8+
CEMIFrameFormat,
9+
CEMIFrameType,
10+
CEMIPriority,
11+
)
612
from xknx.exceptions import ConversionError
713

814

15+
def control_field(ctrl1: int, ctrl2: int) -> int:
16+
"""Return Ctrl1 and Ctrl2 as one 16 bit control field."""
17+
return ctrl1 << 8 | ctrl2
18+
19+
920
@pytest.mark.parametrize(
10-
"raw,flags",
21+
"ctrl1,ctrl2,flags",
1122
[
1223
(
1324
# Ctrl1 0xBC: standard frame, do not repeat, broadcast, low priority
1425
# Ctrl2 0xE0: group address, hop count 6, standard frame format
15-
bytes((0xBC, 0xE0)),
26+
0xBC,
27+
0xE0,
1628
CEMIFlags(priority=CEMIPriority.LOW, hop_count=6),
1729
),
1830
(
1931
# Ctrl1 0x3C: extended frame - kept, but not evaluated
20-
bytes((0x3C, 0xE0)),
32+
0x3C,
33+
0xE0,
2134
CEMIFlags(
2235
priority=CEMIPriority.LOW,
2336
hop_count=6,
@@ -26,13 +39,15 @@
2639
),
2740
(
2841
# Ctrl1 0xB0: system priority; Ctrl2 0x60: individual address
29-
bytes((0xB0, 0x60)),
42+
0xB0,
43+
0x60,
3044
CEMIFlags(priority=CEMIPriority.SYSTEM, hop_count=6),
3145
),
3246
(
3347
# every optional flag set: repeat on error, system broadcast,
3448
# acknowledge requested, confirm error, urgent priority, hop count 7
35-
bytes((0b1000_1011, 0b0111_0000)),
49+
0b1000_1011,
50+
0b0111_0000,
3651
CEMIFlags(
3752
priority=CEMIPriority.URGENT,
3853
repeat_on_error=True,
@@ -44,9 +59,9 @@
4459
),
4560
],
4661
)
47-
def test_from_knx(raw: bytes, flags: CEMIFlags) -> None:
48-
"""Test parsing of Ctrl1 and Ctrl2."""
49-
assert CEMIFlags.from_knx(raw) == flags
62+
def test_from_knx(ctrl1: int, ctrl2: int, flags: CEMIFlags) -> None:
63+
"""Test parsing of the control field."""
64+
assert CEMIFlags.from_knx(control_field(ctrl1, ctrl2)) == flags
5065

5166

5267
@pytest.mark.parametrize(
@@ -62,84 +77,96 @@ def test_from_knx(raw: bytes, flags: CEMIFlags) -> None:
6277
)
6378
def test_frame_format_from_knx(eff: int, frame_format: CEMIFrameFormat) -> None:
6479
"""Test parsing of the Extended Frame Format field."""
65-
assert CEMIFlags.from_knx(bytes((0xBC, 0xE0 | eff))).frame_format is frame_format
80+
flags = CEMIFlags.from_knx(control_field(0xBC, 0xE0 | eff))
81+
assert flags.frame_format is frame_format
6682

6783

6884
@pytest.mark.parametrize("eff", [0b0001, 0b0010, 0b0011, 0b1000, 0b1100, 0b1111])
6985
def test_reserved_frame_format(eff: int) -> None:
7086
"""Test parsing of a reserved Extended Frame Format."""
7187
with pytest.raises(ConversionError, match=r".*Reserved Extended Frame Format.*"):
72-
CEMIFlags.from_knx(bytes((0xBC, 0xE0 | eff)))
88+
CEMIFlags.from_knx(control_field(0xBC, 0xE0 | eff))
7389

7490

7591
@pytest.mark.parametrize(
7692
"priority,ctrl1",
7793
[
78-
(CEMIPriority.SYSTEM, 0b1011_0000),
79-
(CEMIPriority.NORMAL, 0b1011_0100),
80-
(CEMIPriority.URGENT, 0b1011_1000),
81-
(CEMIPriority.LOW, 0b1011_1100),
94+
(CEMIPriority.SYSTEM, 0b0011_0000),
95+
(CEMIPriority.NORMAL, 0b0011_0100),
96+
(CEMIPriority.URGENT, 0b0011_1000),
97+
(CEMIPriority.LOW, 0b0011_1100),
8298
],
8399
)
84100
def test_priority_to_knx(priority: CEMIPriority, ctrl1: int) -> None:
85101
"""Test priority encoding - 3/2/2 §2.2.2 Figure 28."""
86-
raw = CEMIFlags(priority=priority).to_knx(
87-
frame_type=CEMIFrameType.STANDARD, dst_is_group_address=False
88-
)
89-
assert raw[0] == ctrl1
102+
# the Frame Type bit is not set by `CEMIFlags.to_knx()`
103+
assert CEMIFlags(priority=priority).to_knx() >> 8 == ctrl1
90104

91105

92-
def test_to_knx_derived_fields() -> None:
93-
"""Test Frame Type and Address Type are supplied by the frame, not by `flags`."""
94-
flags = CEMIFlags()
95-
assert flags.to_knx(
96-
frame_type=CEMIFrameType.STANDARD, dst_is_group_address=True
97-
) == bytes((0xBC, 0xE0))
98-
assert flags.to_knx(
99-
frame_type=CEMIFrameType.EXTENDED, dst_is_group_address=True
100-
) == bytes((0x3C, 0xE0))
101-
assert flags.to_knx(
102-
frame_type=CEMIFrameType.STANDARD, dst_is_group_address=False
103-
) == bytes((0xBC, 0x60))
106+
def test_to_knx_leaves_derived_bits_clear() -> None:
107+
"""Test Frame Type and Address Type are not set by `CEMIFlags.to_knx()`."""
108+
raw = CEMIFlags().to_knx()
109+
assert raw == control_field(0b0011_1100, 0b0110_0000)
110+
assert CEMIFrameType.from_knx(raw) is CEMIFrameType.EXTENDED # ie. bit not set
111+
assert CEMIAddressType.from_knx(raw) is CEMIAddressType.INDIVIDUAL
112+
113+
114+
@pytest.mark.parametrize(
115+
"frame_type,address_type,expected",
116+
[
117+
(CEMIFrameType.STANDARD, CEMIAddressType.GROUP, (0xBC, 0xE0)),
118+
(CEMIFrameType.EXTENDED, CEMIAddressType.GROUP, (0x3C, 0xE0)),
119+
(CEMIFrameType.STANDARD, CEMIAddressType.INDIVIDUAL, (0xBC, 0x60)),
120+
(CEMIFrameType.EXTENDED, CEMIAddressType.INDIVIDUAL, (0x3C, 0x60)),
121+
],
122+
)
123+
def test_composition(
124+
frame_type: CEMIFrameType,
125+
address_type: CEMIAddressType,
126+
expected: tuple[int, int],
127+
) -> None:
128+
"""Test the frame ORs the derived bits into the flags."""
129+
raw = CEMIFlags().to_knx() | frame_type.to_knx() | address_type.to_knx()
130+
assert raw == control_field(*expected)
104131

105132

106133
def test_to_knx_ignores_received_frame_type() -> None:
107134
"""Test the Frame Type of a received frame is not used when serializing."""
108-
flags = CEMIFlags.from_knx(bytes((0x3C, 0xE0)))
135+
flags = CEMIFlags.from_knx(control_field(0x3C, 0xE0))
109136
assert flags.frame_type is CEMIFrameType.EXTENDED
110-
assert flags.to_knx(
111-
frame_type=CEMIFrameType.STANDARD, dst_is_group_address=True
112-
) == bytes((0xBC, 0xE0))
137+
raw = (
138+
flags.to_knx()
139+
| CEMIFrameType.STANDARD.to_knx()
140+
| CEMIAddressType.GROUP.to_knx()
141+
)
142+
assert raw == control_field(0xBC, 0xE0)
113143

114144

115145
def test_round_trip() -> None:
116-
"""Test parsing and serializing yields the same octets."""
146+
"""Test parsing and serializing yields the same control field."""
117147
for ctrl1 in (0xBC, 0xB0, 0x9C, 0xBF, 0x3C):
118148
for ctrl2 in (0xE0, 0x60, 0xF0, 0x00):
119-
raw = bytes((ctrl1, ctrl2))
149+
raw = control_field(ctrl1, ctrl2)
120150
flags = CEMIFlags.from_knx(raw)
121151
assert (
122-
flags.to_knx(
123-
frame_type=flags.frame_type,
124-
dst_is_group_address=bool(ctrl2 & 0x80),
125-
)
126-
== raw
127-
)
152+
flags.to_knx()
153+
| flags.frame_type.to_knx()
154+
| CEMIAddressType.from_knx(raw).to_knx()
155+
) == raw
128156

129157

130158
@pytest.mark.parametrize("hop_count", [-1, 8, 255])
131159
def test_invalid_hop_count(hop_count: int) -> None:
132160
"""Test hop count out of range."""
133-
flags = CEMIFlags(hop_count=hop_count)
134161
with pytest.raises(ConversionError, match=r".*Hop count out of range.*"):
135-
flags.to_knx(frame_type=CEMIFrameType.STANDARD, dst_is_group_address=True)
162+
CEMIFlags(hop_count=hop_count).to_knx()
136163

137164

138165
def test_str() -> None:
139166
"""Test the compact string representation only lists flags that are set."""
140167
assert str(CEMIFlags()) == "LOW STANDARD hop_count=6"
141168
assert (
142-
str(CEMIFlags.from_knx(bytes((0b1000_1011, 0b0111_0000))))
169+
str(CEMIFlags.from_knx(control_field(0b1000_1011, 0b0111_0000)))
143170
== "URGENT STANDARD hop_count=7 repeat_on_error system_broadcast "
144171
"acknowledge_request confirm_error"
145172
)

test/secure_tests/data_secure_test.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@
77
import pytest
88

99
from xknx import XKNX
10-
from xknx.cemi import CEMIFrame, CEMILData, CEMIMessageCode
11-
from xknx.cemi.flags import FRAME_TYPE_STANDARD
10+
from xknx.cemi import CEMIFrame, CEMIFrameType, CEMILData, CEMIMessageCode
1211
from xknx.dpt import DPTArray
1312
from xknx.exceptions import DataSecureError
1413
from xknx.secure.data_secure import is_data_secure
@@ -483,7 +482,7 @@ def test_data_secure_authentication_only(self) -> None:
483482
# 4 octet plain APDU + 12 octets Data Secure overhead doesn't fit in a
484483
# standard frame - it is serialized as L_Data_Extended frame
485484
assert outgoing_raw[6] == 16
486-
assert not outgoing_raw[0] & FRAME_TYPE_STANDARD
485+
assert CEMIFrameType.from_knx(outgoing_raw[0] << 8) is CEMIFrameType.EXTENDED
487486

488487
# create new cemi to avoid mixed bytearray / byte parts
489488
incoming_cemi = CEMIFrame.from_knx(b"\x11\x00" + outgoing_raw)

xknx/cemi/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,10 @@
1212
)
1313
from .cemi_handler import CEMIHandler
1414
from .const import CEMIErrorCode, CEMIMessageCode
15-
from .flags import CEMIFlags, CEMIFrameFormat, CEMIFrameType, CEMIPriority
15+
from .flags import (
16+
CEMIAddressType,
17+
CEMIFlags,
18+
CEMIFrameFormat,
19+
CEMIFrameType,
20+
CEMIPriority,
21+
)

0 commit comments

Comments
 (0)