Status: Accepted, amended by ADR 0067 (products that share a (type, subtype) are told apart by a model row below it, declared with io_model:) ยท Recorded: 2026-10
Amends ADR 0052 (behaviour keys on the row's capability word) and ADR 0055 point 6 (the window companions read a capability bit).
Context
A garage opener of subtype 58 refused everything the cover offered it except open, close and stop: a 50 % position and the favorite alias both drew PARAMETER_UNKNOWN (issue #163, corpus wisniowski_moto1000_exchange_execute_parameter_unknown and wisniowski_moto1000_exchange_execute_partial_position_refused). The cover still showed a slider and a Favorite Position button, because "what can this device do" was not asked of one place.
It was answered in many: a switch over the node type for tilt, another for the ventilation position, a subtype-58 test for the frame shape, a second copy of that test in the fan platform, a Python list of kinds with a favorite button and another of kinds with a ventilation button, a YAML key for brightness that nothing checked against the kind, and a Python table of heaters with window sensors that a sync test held equal to a C++ one. Each copy could be right alone and still disagree with the others, and adding a capability meant finding all of them.
Decision
- One capability word per row. Each row of the node-type table carries a 16-bit word (DeviceFeatures) of independent bits: MAIN_PARAMETER_ONLY, LEVELS (intermediate values: percent position, brightness, heat level), TILT, the named positions FAVORITE, VENT, PARTIAL and PEDESTRIAN, EXTENDED_POSITION_FRAME, WINDOW_COMPANIONS and INVERTED. A row states its whole word and is never merged with its type's row. Platforms, the hub and the codegen ask that word and never branch on a node type or subtype number for a capability. A bit says whether a device can do something; how it does it (register layouts, modes, presets) stays in a per-family profile, as the heating profile does (ADR 0055).
- One resolved copy per device. resolve_device_features() computes a device record's word from its (type, subtype) and the YAML keys that may override it (invert_position:, dimmable:); the registry runs it whenever a record is stored or learns its type. The table lookup is called by the resolver and by checks that run before a device exists.
- The subtype-58 rule is one function. The VELUX KLF 200 API specification, Appendix 2, defines subtype 58 as "main parameter only" for a few node types. main_parameter_only_variant() applies it to every type and, from issue #163, also reads it as "no intermediate positions": it clears LEVELS, TILT and EXTENDED_POSITION_FRAME (each needs a functional parameter or an intermediate value) and sets MAIN_PARAMETER_ONLY. The generalisation to every type and the reading as "no
intermediate positions" are this component's, not the specification's. The 58 rows and the lookup fallback both use it.
- Named positions are main-parameter values of the family. The specification defines aliases such as the window opener's ventilation value per actuator profile, independent of the functional parameters (KLF 200 section 14.2; section 14.1 gives the stop value). Subtype 58 therefore keeps them. NAMED_POSITIONS is the one table that pairs a position's feature bit, its main-parameter value and the command that sends it; the frame builder, the value check (device_accepts_main_parameter()), the command names and the Python companion buttons all read it. PARTIAL (0xD809) runs the partial position stored in a garage opener, confirmed on the opener of issue #163. PEDESTRIAN (0xD807) has no public source and is experimental.
- EXTENDED_POSITION_FRAME decides the frame shape. A position or force-open EXECUTE uses the 8-byte form with functional parameter 1 set only when the row has the bit; otherwise it uses the 6-byte main-parameter form. On covers, functional parameter 1 is the travel speed on most node types (KLF 200 Appendix 2), so the silent: key needs the bit too. The Appendix lists parameter 1 as the slat orientation on type 1 and the upper curtain on type 13, so those two lose the bit (experimental).
- A capability is removed only when a device has refused it. The garage family loses FAVORITE because the garage opener of issue #163 refused the favorite alias (one device, applied to the whole family, so the subtype 0 row rests on an inference); the window, gate and rolling-door families keep it. An unsupported named position is refused by the motor before authentication and nothing moves, so keeping one costs a refused command, not a movement. Rows 7/58 and 24/58 inherit their family's favorite.
- Undeclared kinds keep their entity's defaults. A device whose type is not declared, or has no row, takes undeclared_kind_features() of the entity that registered it: a cover accepts a slider, the favorite and ventilation positions and the 8-byte frame, a light, switch or lock the 8-byte frame, an exterior-heating fan its heat levels in the 8-byte frame, and no class gets tilt. Once the radio teaches the real type, the row takes over.
- The Python mirror is literal and checked. hub_validators.py carries FEATURE_* and DEVICE_FEATURES (one entry per row of a YAML-named type whose word is not zero, plus the explicit zero where its type's word would otherwise be inherited), and tests/sync/device_features_sync_test.cpp checks every bit and the whole word of every (type, subtype) against the C++ table, the pattern of ADR 0015. A generator was not worth a build step for a table that changes rarely.
- Keys a kind cannot use fail validation. One helper, require_feature(), rejects a key when the declared kind lacks a required bit or has a forbidden one, with a message that names the key, the kind and what to remove. silent: requires EXTENDED_POSITION_FRAME; dimmable: true is refused where the row is MAIN_PARAMETER_ONLY (the 6/0 row has no LEVELS on purpose, because dimmable: decides that for lights). A config that declares no kind passes: nothing says what the device is.
- A cover without LEVELS is a discrete cover. It offers open, close and stop and no slider. It shows closed only when the device reports fully closed (inversion respected) and open for every other report, so a door stopped half-way shows open, and it is an assumed-state cover so Home Assistant keeps both buttons enabled. A position between open and closed is refused at the hub before a frame is built, through the same value check that guards named positions.
- Unconfirmed kinds are marked, and the config says so. A row that no capture or report confirms on a real device carries the EXPERIMENTAL identity flag (subtype rows only). Discovery and pairing logs print "(experimental)" from it, the docs table marks it, and a config that declares one logs a warning at build time that names the kind and links to the page for reporting a result. Python keeps its copy as KIND_EXPERIMENTAL, bit 15 of the same word, so the sync test covers it. Rows 1, 10 and 13 are not flagged: warning every user of those common kinds for a change in one feature would be noise; their changed behaviour is marked experimental in the row's Doxygen and the docs. A second sync test checks the node-type table in the docs against the C++ table.
- Each cover kind has a default Home Assistant device class. A cover that declares io_device_type and no device_class: gets its kind's class (garage, gate, door, window, shutter, blind, shade, curtain, awning). The table is Python-only, because no C++ reads a device class, and an explicit device_class: always wins, including the empty string.
Considered
- Generate the Python mirror from the C++ table. Rejected: a generated file in the build and in review for a table that changes rarely. The sync test turns drift into a failing test instead.
- Keep the per-kind lists and add a sync test for each. Rejected: it keeps one copy per capability and per language, and the lists still disagree about what a subtype changes.
- Remove the favorite from every kind where no device has been seen to accept it. Rejected: a missing confirmation is not a refusal. A bit leaves a row only on a device's refusal.
- Flag rows 1, 10 and 13 as experimental. Rejected for the reason in point 11.
- Round an intermediate position on a discrete cover to open or closed. Rejected: the user asked for something the device cannot do, and a refusal that logs why is clearer than a different move.
Consequences
What existing configurations see:
- The garage Favorite Position button is gone on garage openers of every subtype (5/0 and 5/58). A garage opener of subtype 0 keeps its percent slider; a 5/58 does not.
- Percent positions are refused on 5/58, and 7/58 and 24/58 are open, close and stop covers (experimental, inferred from the shared subtype definition and the 5/58 of issue #163). All three show open or closed, and assume the state so both buttons stay enabled.
- Type 10 loses the tilt slider, and 1/58, 10/58, 17/58 and 18/58 have no tilt, because the Appendix lists no slat parameter for type 10 and subtype 58 clears the bit.
- Types 1 and 13 send the short position frame (6 bytes) and refuse silent:.
- A config with silent: on a kind without a speed parameter fails validation: garage openers, types 1 and 13, and any subtype 58. The same holds for dimmable: true on a light of subtype 58. Each error names the key to remove.
- A cover declared with the raw io_device_type: 0x14 (a ventilation point, not a cover) loses the ventilation button it was given.
- Garage openers get a disabled Partial Position button and gate openers a disabled Pedestrian Position button (the latter experimental); enable them in Home Assistant.
- Covers that declare a kind get its default device_class. A user who wanted no class sets device_class: "".
- Declaring an experimental kind logs a warning when the config is built. It does not stop the build.
- A light without dimmable: and a cover now refuse an intermediate or out-of-range value at the hub, where only the entity used to prevent it. An explicit invert_position: false survives a type learned from the radio.
What it costs:
- The assumed-state trait of a discrete cover follows how Home Assistant's frontend enables the Open and Close buttons, read from its source. It has not yet been exercised against a running instance.
- Traits are read when Home Assistant connects. A cover that declares no kind learns it from the radio, and its traits change only at the next reconnect; the generated YAML block always declares the kind, and the docs say to keep it.
- The subtype-58 reading as "no intermediate positions" rests on one device. A 58 device that does take positions needs a row of its own.
- The PEDESTRIAN value rests on no public source, and PARTIAL on one garage opener. A motor without the position refuses it and nothing moves.
Extending it:
- A capability is one bit with its evidence, the rows that have it, its Python mirror line, an invariant in the table test when it constrains other bits, and a read from the device's resolved word.
- A named position is one NAMED_POSITIONS entry, one CoverCommand value and one companion button row in the Python codegen.
- A device kind is one row.
- Behaviour that differs by manufacturer needs a manufacturer column and a lookup argument; the lookup's signature leaves room for it.
- A cover family with a different wire form needs a per-family profile, as heaters have, once a capture or a reporter shows the form.