Skip to content

Refactor CEMILData.flags into a typed CEMIFlags dataclass - #1884

Draft
farmio wants to merge 3 commits into
mainfrom
refactor-cemi-flags
Draft

Refactor CEMILData.flags into a typed CEMIFlags dataclass#1884
farmio wants to merge 3 commits into
mainfrom
refactor-cemi-flags

Conversation

@farmio

@farmio farmio commented Jul 31, 2026

Copy link
Copy Markdown
Member

Description

Follow-up to #1882, targeted at the next major release. CEMILData.flags was a bare 16 bit int and CEMIFlags a bag of bit constants, so callers had to know the layout of Ctrl1 and Ctrl2 to read or set anything, and hops needed a hand written mask/shift property.

CEMIFlags is now a slotted dataclass holding the control field values that are actually independent state:

@dataclass(slots=True)
class CEMIFlags:
    priority: CEMIPriority = CEMIPriority.LOW
    # `repeat_on_error` and `system_broadcast` are inverted on the wire
    repeat_on_error: bool = False
    system_broadcast: bool = False
    acknowledge_request: bool = False
    confirm_error: bool = False
    hop_count: int = 6

enum.Flag was considered and does not fit: priority is a 2 bit value and the hop count a 3 bit counter, neither of which are flags. A plain slotted dataclass gives free __eq__, keyword construction and mutability for the hop count, which is what a router would need.

What is not stored

Frame Type, Address Type and Extended Frame Format are deliberately not fields. Each is determined by something the frame already knows:

field derived from
Frame Type (Ctrl1 b7) NPDU length - <= 15 is a standard frame
Address Type (Ctrl2 b7) isinstance(dst_addr, GroupAddress)
Extended Frame Format (Ctrl2 b3..0) always 0; anything else is rejected when parsing

CEMIFlags.to_knx() takes the first two as keyword arguments from the frame that knows them:

def to_knx(self, *, frame_type_standard: bool, dst_is_group_address: bool) -> bytes:

This means flags can no longer disagree with what is put on the wire - the class of bug that made #1868 possible - and there is no stale Frame Type left to go wrong when Data Secure replaces the payload of a frame it copied.

On the int | None idea

Storing raw incoming flags in a private attribute was considered and rejected. Parsing straight into the dataclass loses only the Frame Type (deliberately) and the reserved Ctrl1 b6; everything else round-trips, including for a received frame. Letting a stored raw value take precedence when serializing would reintroduce exactly the stale-flags problem, since a copy()d frame with a swapped payload would emit the old Frame Type. If you want byte-exact fidelity for debugging, the raw octets of a received frame are already available in CEMIHandler.handle_raw_cemi - keeping a second, partly authoritative copy on the object is the part that does not pay for itself.

Happy to add it as a debug-only field if you disagree - it is a small addition on top of this.

Also in this change

  • CEMILData.hops is replaced by flags.hop_count, validated to 0..7 when serializing instead of silently corrupting Ctrl2. CEMILData.dst_is_group_address is added.
  • The raw bit masks move to xknx.cemi.flags as per-octet constants. Mixing 8 and 16 bit values (FRAME_TYPE_STANDARD = 0x8000 next to DESTINATION_GROUP_ADDRESS = 0x0080) was a standing source of confusion.
  • block_0(), SecureData.init_from_plain_apdu() and SecureData.get_plain_apdu() take dst_is_group_address: bool instead of frame_flags: int. Only Ctrl2's Address Type bit ever survived B0_AT_FIELD_FLAGS_MASK, so the mask is gone and the CCM input is unchanged.
  • CEMILData(flags=...) is optional and defaults to CEMIFlags().

Verification

The Data Secure byte vectors are the strongest check that nothing moved: the AN158 v07 Annex A example and the real-device frame still verify, and the secured outgoing frames are byte-identical to what main produces, including the extended-frame vector from #1882:

3ce0500104001103f11000254ae1cb67cd98e577b519be47bb

New test/cemi_tests/flags_test.py covers parsing, the priority encoding from 3/2/2 Figure 28, the derived-field arguments, a Ctrl1/Ctrl2 round trip and hop count validation. Full suite passes (3758); test/integration_tests/routing_multicast_test.py fails on my machine for lack of multicast routing, identically on main.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Code quality improvements to existing code or addition of tests

Checklist

  • The documentation has been adjusted accordingly
  • Tests have been added that prove the fix is effective or that the feature works
  • The changes are documented in the changelog (docs/changelog.md)

🤖 Generated with Claude Code

`CEMILData.flags` was a bare 16 bit `int` and `CEMIFlags` a bag of bit
constants, so callers had to know the layout of Ctrl1 and Ctrl2 to read or
set anything, and `hops` needed a hand written mask/shift property.

Make `CEMIFlags` a slotted dataclass holding the control field values that
are actually independent state - priority, repeat_on_error, system_broadcast,
acknowledge_request, confirm_error and hop_count - and add a `CEMIPriority`
enum. `enum.Flag` does not fit: priority is a 2 bit value and the hop count a
3 bit counter, neither of which are flags.

Frame Type, Address Type and Extended Frame Format are deliberately *not*
stored. Each of them is determined by something the frame already knows: the
NPDU length, the type of the destination address, and the fact that only
frame format 0 is supported. Deriving them when serializing means `flags` can
no longer disagree with what is put on the wire - the class of bug that
required the L_Data_Extended fix in the first place - and there is no stale
Frame Type left to go wrong when Data Secure replaces the payload of a frame
it copied.

An `int | None` of raw incoming flags was considered and rejected. Parsing
into the dataclass loses only the Frame Type (deliberately) and the reserved
Ctrl1 b6, and letting a stored raw value take precedence when serializing
would reintroduce exactly the stale-flags problem. The raw octets of a
received frame remain available to `CEMIHandler.handle_raw_cemi`.

Also:
- `hops` is replaced by `flags.hop_count`, validated to 0..7 when serializing
  instead of silently corrupting Ctrl2.
- the raw bit masks move to `xknx.cemi.flags` as per-octet constants; mixing
  8 and 16 bit values (0x8000 vs 0x0080) was a standing source of confusion.
- `block_0()` and the `SecureData` methods take `dst_is_group_address: bool`
  instead of `frame_flags: int`. Only Ctrl2's Address Type bit ever survived
  `B0_AT_FIELD_FLAGS_MASK`, so the mask is gone and the CCM input is
  unchanged - the AN158 Annex A and real-device vectors still verify and the
  secured frame byte vectors are identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.77%. Comparing base (182e5d3) to head (3963bc6).

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main    #1884   +/-   ##
=======================================
  Coverage   97.76%   97.77%           
=======================================
  Files         181      182    +1     
  Lines       13192    13236   +44     
=======================================
+ Hits        12897    12941   +44     
  Misses        295      295           
Files with missing lines Coverage Δ
xknx/cemi/__init__.py 100.00% <100.00%> (ø)
xknx/cemi/cemi_frame.py 99.59% <100.00%> (-0.01%) ⬇️
xknx/cemi/const.py 100.00% <ø> (ø)
xknx/cemi/flags.py 100.00% <100.00%> (ø)
xknx/secure/data_secure.py 97.89% <ø> (ø)
xknx/secure/data_secure_asdu.py 97.46% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread xknx/cemi/cemi_frame.py Outdated
Comment on lines +244 to +227
if _eff := flags & CEMIFlags.EXTENDED_FRAME_FORMAT_MASK:
if _eff := raw[1] & EXTENDED_FRAME_FORMAT_MASK:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this this check runs on raw. Should we add an enum / dataclasses that holds frametype?
I'd like to be able to hold (log) it for incoming frames while auto-setting it for outgoing. It would be merged with the flags .to_knx() and the address type when sending.

For address type we can see the received value from the type of dst_addr, so that's fine. Original frame format however is lost.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code 🤖: Done in c072f10 — both the typed frame type and the parsed check.

CEMIFlags gains two informational fields that hold what was received and are ignored when serializing:

frame_type: CEMIFrameType = CEMIFrameType.STANDARD    # EXTENDED / STANDARD
frame_format: CEMIFrameFormat = CEMIFrameFormat.STANDARD  # STANDARD / LTE_HEE

to_knx() takes the Frame Type as a CEMIFrameType from CEMILData, which derives it from the NPDU length, so self.frame_type is never consulted when sending — exactly the "hold it for incoming, auto-set for outgoing, merge in to_knx()" split you described. test_to_knx_ignores_received_frame_type pins that.

The EFF check no longer touches raw bits:

if flags.frame_format is not CEMIFrameFormat.STANDARD:
    raise UnsupportedCEMIMessage(
        f"Extended Frame Format not supported: {flags.frame_format.name} ..."
    )

CEMIFrameFormat._missing_ resolves the whole 01xxb range to LTE_HEE (the 2 lsb are the LTE extended address type, so enumerating them separately would be noise). Reserved encodings raise ConversionError out of CEMIFlags.from_knx, which CEMILData.from_knx converts to UnsupportedCEMIMessage with the source address and raw frame — the same pattern already used for an unsupported TPCI. Both paths are parametrised in cemi_frame_test.py.

One thing I did not do: serialize self.frame_format. to_knx() always writes STANDARD, because a CEMILData with any other format cannot exist — it is rejected during parsing. Storing it is still worth it for the typed check and for logging, but writing it back would only matter if we ever supported LTE.

Address type is unchanged, readable from dst_addr and also exposed as CEMILData.dst_is_group_address.

Also added the compact __str__ you asked for: LOW STANDARD hop_count=6, listing only the booleans that are set, used by CEMILData.__repr__ so debug log lines stay short. The full dataclass __repr__ still shows every field.

The Data Secure byte vectors are unchanged, including 3ce0500104001103f11000254ae1cb67cd98e577b519be47bb.

farmio and others added 2 commits July 31, 2026 12:16
Addresses review feedback on #1884: the Extended Frame Format check ran on
`raw[1]` rather than on a parsed value, and the Frame Type a frame arrived
with was discarded, so it could not be logged.

Add `CEMIFrameType` (STANDARD / EXTENDED) and `CEMIFrameFormat` (STANDARD /
LTE_HEE, with `_missing_` resolving the whole 01xxb range to LTE_HEE) and
keep both on `CEMIFlags` as informational fields: they hold what was
received and are ignored when serializing. `CEMILData.to_knx()` still passes
the Frame Type it derives from the NPDU length, and the Address Type stays
out of the dataclass - it is readable from the type of `dst_addr`.

`CEMIFlags.from_knx()` now raises `ConversionError` for a reserved frame
format, which `CEMILData.from_knx()` turns into `UnsupportedCEMIMessage`
with the source address and raw frame, the same way it already handles an
unsupported TPCI. The LTE check compares a typed value.

Add a compact `CEMIFlags.__str__` - "LOW STANDARD hop_count=6", listing only
the boolean flags that are set - and use it in `CEMILData.__repr__` to keep
per-frame debug log lines readable. `__repr__` is the full dataclass repr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`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>
@farmio
farmio force-pushed the refactor-cemi-flags branch from 3ead45a to 3963bc6 Compare July 31, 2026 10:27

@kewde kewde left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

utACK

sequence_number=sequence_number_bytes,
address_fields_raw=address_fields_raw,
frame_flags=frame_flags,
address_type=address_type,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice, much easier to see what gets included in the MAC 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants