room-library: Theben-benchmark corrections (dogfood on 5 official ETS examples)

Ran KNX Association-ecosystem vendor (Theben) 5 official ETS example .knxproj
through our QA. Findings folded back into the product:

- repair.py _infer_dpt: relative dimming (Brighter/darker, heller/dunkler,
  светлее/темнее) now infers 3.007 DPT_Control_Dimming instead of falling to
  1.001; explicit '% / value' stays 5.001; scene checked before the broad
  value branch. DPTs verified against XKNX/xknx via deepwiki.
- room_library.py: new reusable blocks distilled from vendor patterns —
  shutter_venetian (slat + slat-status + wind/frost safety-lock), climate_floor
  modulating actuating-value + status, ventilation (airing stage + CO2 +
  humidity), lighting_dali, multisensor_air, central scene/all-off/all-on.
- project.py classifier: airing/Lüftung/проветривание -> hvac (was unknown).
- analyze.py: safety_input_no_status INFO exemption for write-only wind/frost
  lock inputs (Theben central wind-safety pattern).
- SCHEMA.md: new function rows; automation_intents implementation: knx|external
  with safety_related => knx rule; DPT-tolerance note (missing-DPT hard error
  applies to generated output; ETS4 object-derived DPT on import is legitimate).
- templates: ventilation slot (bathroom/kitchen), opt-in venetian + wind/frost
  safety intent (living/bedroom), new house-level central.yaml.
- tests: +test_theben_corrections.py (10); golden bedroom 26->28 GA (climate
  actuating-value). pytest 20 passed (pre-existing unrelated test_protocol
  collection error untouched). senior-code-audit: APPROVED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nikolay1
2026-07-16 11:21:18 +02:00
co-authored by Claude Opus 4.8
parent f0d1afdce2
commit bf7eba8898
12 changed files with 446 additions and 13 deletions
+29
View File
@@ -188,6 +188,22 @@ def _is_scene(ga: GARecord) -> bool:
return ga.dpt_main in (17, 18) or ga.category == "scene"
# A wind/frost safety-lock is a WRITE-ONLY 1-bit input the shutter actuator listens
# to (the KNX safety logic drives it); it has no feedback object, so a missing
# status is expected, not a defect. Gated to 1-bit so a "frost protection setpoint"
# (9.x) never matches. This is the Theben-benchmark safety_related lesson: the lock
# stays in KNX, never in network-dependent Home Assistant.
_SAFETY_INPUT_TOKENS = (
"safety lock", "блокировк", "sperre", "verriegel", "wind lock",
"wind/frost", "ветров", "мороз", "frost lock", "windsperre", "frostsperre",
)
def _is_safety_input(ga: GARecord) -> bool:
low = (ga.name or "").lower()
return ga.dpt_main == 1 and any(t in low for t in _SAFETY_INPUT_TOKENS)
# --------------------------------------------------------------------------- #
# Sub-DPT sanity (A1): a name implies a specific DPT sub-type. Flag when the
# main matches but the sub is wrong (9.001 temp vs 9.004 lux), or — for strong
@@ -255,6 +271,19 @@ def detect_missing_status(project: LoadedProject) -> list[dict[str, Any]]:
continue
if ga.dpt_main is None:
continue # DPT issue handled elsewhere
# A wind/frost safety-lock is a write-only INPUT — recognise it up front so
# it is never (spuriously) paired to a position status, and never demands a
# feedback object of its own (safety_related logic stays in KNX).
if _is_safety_input(ga):
findings.append(_finding(
SEVERITY_INFO, "safety_input_no_status", addr,
f"Safety/lock input '{ga.name}' has no status GA — expected: a "
"wind/frost safety-lock is a write-only input to the actuator, it has "
"no state to read back. Keep this protection in KNX (safety_related), "
"never in network-dependent Home Assistant.",
name=ga.name, dpt=ga.dpt, category=ga.category,
))
continue
# prefer same-main-group candidates, fall back to whole project
same_main = [s for s in status_gas if s.main == ga.main]
match = find_status(ga, same_main) or find_status(ga, status_gas)
+4 -1
View File
@@ -58,7 +58,10 @@ _DOMAIN_TERMS: dict[str, list[str]] = {
"hvac": ["hvac", "climate", "thermostat", "heat", "cool", "ac", "a/c", "aircon",
"air con", "air-con", "conditioner", "ventilation", "radiator", "boiler",
"underfloor", "fancoil", "fan coil", "fan-coil", "fan", "valve", "setpoint",
"климат", "отопл", "тёпл", "тепл", "конвектор", "вентил",
# ventilation / airing (F5 fix): "Airing step" was 'unknown' — an airing
# stage is HVAC. lüftung/lüften carry the DE umlaut and an ASCII fallback.
"airing", "lüftung", "lüften", "luftung", "luften",
"климат", "отопл", "тёпл", "тепл", "конвектор", "вентил", "проветр",
"клапан", "кондиц", "котёл", "радиатор", "фанкойл", "уставк",
"а/с", "сплит", "вытяжк"],
# NOTE: no bare "температур"/"temperature" term — a temperature GA takes its
+20 -2
View File
@@ -46,10 +46,28 @@ def _infer_dpt(ga: Any) -> str:
return "1.010"
if "позиц" in low or "position" in low or "stellung" in low:
return "5.001"
if any(k in low for k in ("диммир", "dimming", "яркост", "brightness")):
return "5.001"
# Scene BEFORE the broad "value" branch so a "Scene value"/"Szene Wert" GA
# maps to 18.001, not 5.001 (a Theben "Dimming value %" carries no scene token,
# so it still falls through to 5.001 below).
if "сцен" in low or "scene" in low or "szene" in low:
return "18.001"
# Explicit ABSOLUTE brightness/dimming VALUE (percent / "value" / "dimming
# value" / vendor Dimmwert) -> 5.001 scaling. Checked BEFORE relative dimming
# so a Theben "Dimming value % …" GA stays 5.001 and is not flipped to 3.007.
if any(k in low for k in ("%", "значение", "value", "dimmwert", "helligkeitswert")):
return "5.001"
# RELATIVE dimming (brighter/darker step control) -> 3.007 DPT_Control_Dimming
# (4-bit relative dimming). Verified against the KNX DPT catalogue
# (XKNX/xknx via deepwiki: 3.007 == control bit + 3-bit step_code). This is the
# dogfood bug fix: "Brighter/ darker …" used to fall through to the 1.001
# default, contradicting the object-derived family "3" xknxproject reports.
if any(k in low for k in ("brighter", "darker", "heller", "dunkler",
"светлее", "темнее", "ярче", "тусклее")):
return "3.007"
# Generic dimming/brightness keyword with no explicit value and no relative
# token -> absolute brightness scaling is the safer default.
if any(k in low for k in ("диммир", "dimming", "яркост", "brightness")):
return "5.001"
if ga.category == "shutter":
return "1.008"
# switch-like boolean is the safest default
+68 -4
View File
@@ -79,9 +79,11 @@ MIDDLE_ROLE: dict[str, dict[str, int]] = {
"lighting": {"cmd_onoff": 0, "dimming": 1, "brightness_value": 2,
"status_onoff": 3, "brightness_status": 4},
"shutter": {"move": 0, "stop": 1, "position": 2, "slat": 3,
"position_status": 4},
"position_status": 4, "slat_status": 5, "safety_lock": 6},
# fan(3) is a reserved role slot (no template function uses it yet); airing(6)
# carries the ventilation stage, valve(7) the modulating actuating-value.
"hvac": {"onoff": 0, "mode": 1, "setpoint": 2, "fan": 3, "status": 4,
"actual_temp": 5},
"actual_temp": 5, "airing": 6, "valve": 7},
"sensor": {"presence": 0, "illuminance": 1, "climate": 2},
"central": {"scene": 0, "command": 1},
}
@@ -96,13 +98,17 @@ MIDDLE_LABELS: dict[str, dict[int, dict[str, str]]] = {
1: {"ru": "Стоп", "en": "Stop"},
2: {"ru": "Позиция", "en": "Position"},
3: {"ru": "Ламели", "en": "Slats"},
4: {"ru": "Статус позиции", "en": "Position status"}},
4: {"ru": "Статус позиции", "en": "Position status"},
5: {"ru": "Статус ламелей", "en": "Slat status"},
6: {"ru": "Блокировка (защита)", "en": "Safety lock"}},
"hvac": {0: {"ru": "Вкл/Выкл", "en": "On/Off"},
1: {"ru": "Режим", "en": "Mode"},
2: {"ru": "Уставка", "en": "Setpoint"},
3: {"ru": "Вентилятор", "en": "Fan"},
4: {"ru": "Статусы", "en": "Statuses"},
5: {"ru": "Температура факт", "en": "Actual temperature"}},
5: {"ru": "Температура факт", "en": "Actual temperature"},
6: {"ru": "Проветривание", "en": "Ventilation"},
7: {"ru": "Клапан", "en": "Valve"}},
"sensor": {0: {"ru": "Присутствие/Движение", "en": "Presence/Motion"},
1: {"ru": "Освещённость", "en": "Illuminance"},
2: {"ru": "Климат в комнатах", "en": "Room climate"}},
@@ -150,12 +156,35 @@ FUNCTION_OBJECTS: dict[str, list[ObjSpec]] = {
ObjSpec("onoff_status", "статус", "status", 1, 11, "status", "lighting", "status_onoff"),
ObjSpec("brightness_status", "яркость статус", "brightness status", 5, 1, "status", "lighting", "brightness_status"),
],
"lighting_dali": [
# DALI is a downstream bus behind a gateway; from the KNX side a DALI group
# exposes the SAME objects as a dimmer channel (the DALI specifics live
# inside the gateway). No invented DPTs — a 1:1 clone of lighting_dimmer.
ObjSpec("onoff", "вкл/выкл", "on/off", 1, 1, "command", "lighting", "cmd_onoff"),
ObjSpec("dimming", "диммирование", "dimming", 3, 7, "command", "lighting", "dimming"),
ObjSpec("brightness", "яркость", "brightness", 5, 1, "command", "lighting", "brightness_value"),
ObjSpec("onoff_status", "статус", "status", 1, 11, "status", "lighting", "status_onoff"),
ObjSpec("brightness_status", "яркость статус", "brightness status", 5, 1, "status", "lighting", "brightness_status"),
],
"shutter": [
ObjSpec("updown", "вверх/вниз", "up/down", 1, 8, "command", "shutter", "move"),
ObjSpec("stop", "стоп", "stop", 1, 10, "command", "shutter", "stop"),
ObjSpec("position", "позиция", "position", 5, 1, "command", "shutter", "position"),
ObjSpec("position_status", "позиция статус", "position status", 5, 1, "status", "shutter", "position_status"),
],
# Venetian / façade blind: a roller shutter PLUS slat-angle control (the roller
# `shutter` models only up/down/position). Slat command + slat status close the
# feedback gap for the angle; the wind/frost safety-lock is a WRITE-ONLY input
# to the actuator (no status object exists for it — see analyze._is_safety_input).
"shutter_venetian": [
ObjSpec("updown", "вверх/вниз", "up/down", 1, 8, "command", "shutter", "move"),
ObjSpec("stop", "стоп", "stop", 1, 10, "command", "shutter", "stop"),
ObjSpec("position", "позиция", "position", 5, 1, "command", "shutter", "position"),
ObjSpec("position_status", "позиция статус", "position status", 5, 1, "status", "shutter", "position_status"),
ObjSpec("slat", "ламели", "slat", 5, 1, "command", "shutter", "slat"),
ObjSpec("slat_status", "ламели статус", "slat status", 5, 1, "status", "shutter", "slat_status"),
ObjSpec("safety_lock", "блокировка защита", "safety lock", 1, 1, "command", "shutter", "safety_lock"),
],
"climate_floor": [
ObjSpec("onoff", "вкл/выкл", "on/off", 1, 1, "command", "hvac", "onoff"),
ObjSpec("setpoint", "уставка", "setpoint", 9, 1, "command", "hvac", "setpoint"),
@@ -164,18 +193,53 @@ FUNCTION_OBJECTS: dict[str, list[ObjSpec]] = {
ObjSpec("setpoint_status", "уставка статус", "setpoint status", 9, 1, "status", "hvac", "status"),
ObjSpec("mode_status", "режим статус", "mode status", 20, 102, "status", "hvac", "status"),
ObjSpec("actual_temp", "температура факт", "actual temperature", 9, 1, "sensor", "hvac", "actual_temp"),
# Modulating valve: a continuous actuating-value (%) command + its status.
# A real underfloor/radiator loop is driven continuously, not just on/off,
# and without a status HA cannot show the real valve opening.
ObjSpec("actuating_value", "уровень клапана", "actuating value", 5, 1, "command", "hvac", "valve"),
ObjSpec("actuating_value_status", "уровень клапана статус", "actuating value status", 5, 1, "status", "hvac", "status"),
],
# Ventilation / airing: a discrete airing STAGE (1-byte counter, DPT 5.010 —
# a device/integrator choice, see 01-dpt-derivation.md) plus its feedback, and
# the air-quality sensors that drive it (CO₂ 9.008, humidity 9.007).
"ventilation": [
ObjSpec("airing_stage", "ступень проветривания", "airing stage", 5, 10, "command", "hvac", "airing"),
ObjSpec("airing_stage_status", "ступень проветривания статус", "airing stage status", 5, 10, "status", "hvac", "status"),
ObjSpec("co2", "CO2", "CO2", 9, 8, "sensor", "sensor", "climate"),
ObjSpec("humidity", "влажность", "humidity", 9, 7, "sensor", "sensor", "climate"),
],
"presence": [
ObjSpec("occupancy", "присутствие", "occupancy", 1, 18, "sensor", "sensor", "presence"),
ObjSpec("illuminance", "освещённость", "illuminance", 9, 4, "sensor", "sensor", "illuminance"),
],
# Air-quality multisensor: self-reporting temperature + humidity + CO₂ (no
# command, so no status pair — these are read-only measurements).
"multisensor_air": [
ObjSpec("temperature", "температура", "temperature", 9, 1, "sensor", "sensor", "climate"),
ObjSpec("humidity", "влажность", "humidity", 9, 7, "sensor", "sensor", "climate"),
ObjSpec("co2", "CO2", "CO2", 9, 8, "sensor", "sensor", "climate"),
],
# House-level central functions (main 0). A scene control (18.001, no status —
# a scene has no single state) and per-domain all-off / all-on broadcasts
# (1.001, central macros — no single state to read back).
"central_scene": [
ObjSpec("scene", "сцена", "scene", 18, 1, "command", "central", "scene"),
],
"central_all_off": [
ObjSpec("all_off", "всё выкл", "all off", 1, 1, "command", "central", "command"),
],
"central_all_on": [
ObjSpec("all_on", "всё вкл", "all on", 1, 1, "command", "central", "command"),
],
}
# Function type -> candidate device recipe (device_library) for the BOM proposal.
BOM_RECIPE: dict[str, str] = {
"lighting_switch": "switch_output",
"lighting_dimmer": "dimmer_channel",
"lighting_dali": "dali_gateway_group",
"shutter": "shutter_channel",
"shutter_venetian": "shutter_channel",
"climate_floor": "floor_heating_zone",
"presence": "presence_detector",
}
+46 -2
View File
@@ -66,13 +66,50 @@ with a canonical DPT and a command/status role:
|----------|----------------------|
| `lighting_switch` | on/off `1.001` · status `1.011` |
| `lighting_dimmer` | on/off `1.001` · dimming `3.007` · brightness `5.001` · status `1.011` · brightness-status `5.001` |
| `lighting_dali` | 1:1 clone of `lighting_dimmer` (DALI specifics live inside the gateway; BOM recipe `dali_gateway_group`) |
| `shutter` | up/down `1.008` · stop `1.010` · position `5.001` · position-status `5.001` |
| `climate_floor` | on/off `1.001` · setpoint `9.001` · mode `20.102` · +3 statuses · actual-temp `9.001` |
| `shutter_venetian` | `shutter` + slat `5.001` · slat-status `5.001` + safety-lock `1.001` (write-only input — see below) |
| `climate_floor` | on/off `1.001` · setpoint `9.001` · mode `20.102` · +3 statuses · actual-temp `9.001` · actuating-value `5.001` · actuating-value-status `5.001` |
| `ventilation` | airing-stage `5.010` · airing-stage-status `5.010` · CO₂ `9.008` · humidity `9.007` |
| `multisensor_air` | temperature `9.001` · humidity `9.007` · CO₂ `9.008` (self-reporting, no command → no status pair) |
| `presence` | occupancy `1.018` · illuminance `9.004` |
| `central_scene` | scene `18.001` (no status — a scene has no single state) |
| `central_all_off` / `central_all_on` | all-off / all-on `1.001` (central macro — no single state to read back) |
Every controllable command gets its status object — the generated project passes
`check_missing_status`, `check_dpt`, `check_naming` and `check_policy` cleanly.
Three object classes legitimately have **no status pair**, and the linters treat
them as expected (INFO, not a warning), so a house using them is still clean:
* **safety-lock** input (`shutter_venetian`): a wind/frost lock is a *write-only*
1-bit input the actuator listens to — there is no feedback object
(`analyze._is_safety_input` → `safety_input_no_status`);
* **scene control** (`central_scene`, `17.x`/`18.x`): a scene recalls a preset,
it has no single state (`scene_no_status`);
* **central macros** (`central_all_off`/`_all_on`, "all off"/"all on"/"всё"): a
broadcast fans out to many actuators (`central_macro_no_status`).
### Status/feedback tokens (pairing)
The pairing engine treats **`Feedback` / `Rückmeldung` / `статус` / `state` /
`status`** (and `fb`, `rueck`, `rück`) as first-class status tokens — a vendor
that names its only status object just "Feedback" (e.g. Theben) still pairs. The
token list lives in `project.STATUS_KEYWORDS` and `pairing._FN_STATUS_TOKENS`.
### DPT tolerance (generation vs import)
"**Missing DPT = hard error**" (`check_dpt`) is a rule about **our generated
output**: the Room Library always emits fully DPT-typed GAs, so a missing DPT in
our own output is a real bug. It is *not* a judgement on a third-party **ETS4
import**: in ETS4-era projects the DPT legitimately lives on the device's
communication object, not on the GA, so `xknxproject` derives it (or reports
`missing_dpt` where the object link is unresolved). Auto-completing that DPT is a
feature, not a defect flag on the vendor — see
[`docs/roadmap/room-library/theben-benchmark/01-dpt-derivation.md`](../../../docs/roadmap/room-library/theben-benchmark/01-dpt-derivation.md).
Reserve GAs are intentionally left **without a DPT** — that is a convention (a
spare slot), not a KNX-required value.
## Address allocation (default taxonomy)
`main` = function domain, `middle` = role/sub-function, `sub` = sequential:
@@ -96,9 +133,16 @@ automation_intents:
- intent: presence_lights_off
description: "Turn lights off when unoccupied."
criticality: convenience # or safety_related
implementation: external
implementation: external # external (HA) | knx
```
`implementation` is `external` (Home Assistant) or `knx` (the autonomous KNX
program). **Rule: `criticality: safety_related` ⇒ `implementation: knx`.**
Safety protection (wind/frost blind retract, leak→water-shutoff, fire) must run
autonomously in KNX and must never depend on a network-reachable HA. The template
lays the GA **hook** (e.g. the `shutter_venetian` safety-lock input); the
integrator tunes the **thresholds** (wind m/s, frost °C, timers) in KNX.
## Not in R1
Docking into an existing project (allocation lockfile, drift detection), exact
@@ -42,6 +42,12 @@ slots:
basic: {enabled: false}
comfort: {enabled: true, function: climate_floor, multiplicity: {fixed: 1}}
- slot_id: ventilation
labels: {ru: вентиляция, en: ventilation}
presets:
basic: {enabled: false}
comfort: {enabled: true, function: ventilation, multiplicity: {fixed: 1}}
automation_intents:
- intent: humidity_ventilation
description: "Run ventilation above a humidity threshold."
+19 -1
View File
@@ -15,6 +15,9 @@ parameters:
windows:
default: 2
provenance: {source: preset_rule, note: "typical master bedroom has 2 windows"}
venetian_windows:
default: 0
provenance: {source: preset_rule, note: "opt-in: set >0 where blinds have slats (façade/venetian)"}
area_m2:
default: 14
role: hint
@@ -42,6 +45,15 @@ slots:
basic: {enabled: true, function: shutter, multiplicity: {param: windows}}
comfort: {enabled: true, function: shutter, multiplicity: {param: windows}}
# Venetian / façade blinds (slat control + wind/frost safety-lock). Opt-in:
# emits nothing until venetian_windows > 0, so a plain roller-shutter house is
# unchanged. Where windows carry slats, set venetian_windows.
- slot_id: venetian_blind
labels: {ru: венецианские жалюзи, en: venetian blind}
presets:
basic: {enabled: true, function: shutter_venetian, multiplicity: {param: venetian_windows}}
comfort: {enabled: true, function: shutter_venetian, multiplicity: {param: venetian_windows}}
- slot_id: presence
labels: {ru: датчик, en: sensor}
presets:
@@ -55,9 +67,15 @@ slots:
comfort: {enabled: true, function: climate_floor, multiplicity: {fixed: 1}}
# Non-executable declarations only (metadata). The engine does NOT run these —
# safety-related logic stays external (Home Assistant / the KNX program).
# safety-related logic stays in KNX (autonomous), convenience logic external.
automation_intents:
- intent: presence_lights_off
description: "Turn bedroom lights off when unoccupied."
criticality: convenience
implementation: external
# Safety-related: wind/frost protection MUST stay in KNX, never in HA — the
# actuator listens on the shutter_venetian safety-lock input.
- intent: wind_frost_blind_protection
description: "Retract/lock blinds on high wind or frost (via the safety-lock input)."
criticality: safety_related
implementation: knx
@@ -0,0 +1,52 @@
# Room Template — house-level Central functions (Центральные функции)
# NOT per-room: composed once for the whole house. A small set of scene controls
# (18.001) plus per-domain all-off / all-on broadcasts (1.001). SCHEMA reserves
# main group 0 Central for exactly this. Keep the scene set small — panels and the
# KNX program assemble the detailed logic; the template only lays the GA hooks.
schema_version: 1
slot_id: central
labels:
ru: Центральные функции
en: Central
parameters:
scenes:
default: 4
provenance: {source: preset_rule, note: "keep small — a few house scenes; the rest live on panels"}
area_m2:
default: 0
role: hint
provenance: {source: preset_default, user_overridden: false, note: "house-level, not an area"}
slots:
- slot_id: house_scenes
labels: {ru: сцены, en: scenes}
presets:
basic: {enabled: true, function: central_scene, multiplicity: {param: scenes}}
comfort: {enabled: true, function: central_scene, multiplicity: {param: scenes}}
- slot_id: lighting_all_off
labels: {ru: освещение всё выкл, en: lighting all off}
presets:
basic: {enabled: true, function: central_all_off, multiplicity: {fixed: 1}}
comfort: {enabled: true, function: central_all_off, multiplicity: {fixed: 1}}
- slot_id: lighting_all_on
labels: {ru: освещение всё вкл, en: lighting all on}
presets:
basic: {enabled: false}
comfort: {enabled: true, function: central_all_on, multiplicity: {fixed: 1}}
- slot_id: shutters_all_off
labels: {ru: шторы всё выкл, en: shutters all off}
presets:
basic: {enabled: true, function: central_all_off, multiplicity: {fixed: 1}}
comfort: {enabled: true, function: central_all_off, multiplicity: {fixed: 1}}
# Metadata only. Central all-off / scene recall is convenience logic assembled on
# panels / in the KNX program; safety-related protection lives per-actuator in KNX.
automation_intents:
- intent: house_all_off
description: "One-touch all-off across lighting and shutters."
criticality: convenience
implementation: knx
@@ -42,6 +42,12 @@ slots:
basic: {enabled: false}
comfort: {enabled: true, function: presence, multiplicity: {fixed: 1}}
- slot_id: ventilation
labels: {ru: вентиляция, en: ventilation}
presets:
basic: {enabled: false}
comfort: {enabled: true, function: ventilation, multiplicity: {fixed: 1}}
automation_intents:
- intent: presence_lights_off
description: "Turn kitchen lights off when unoccupied."
+20
View File
@@ -12,6 +12,9 @@ parameters:
windows:
default: 3
provenance: {source: preset_rule}
venetian_windows:
default: 0
provenance: {source: preset_rule, note: "opt-in: set >0 where blinds have slats (façade/venetian)"}
area_m2:
default: 28
role: hint
@@ -37,6 +40,16 @@ slots:
basic: {enabled: true, function: shutter, multiplicity: {param: windows}}
comfort: {enabled: true, function: shutter, multiplicity: {param: windows}}
# Venetian / façade blinds (slat control + wind/frost safety-lock). Opt-in:
# emits nothing until venetian_windows > 0, so a plain roller-shutter house is
# unchanged. Where windows carry slats, set venetian_windows and drop the roller
# count in `windows` accordingly.
- slot_id: venetian_blind
labels: {ru: венецианские жалюзи, en: venetian blind}
presets:
basic: {enabled: true, function: shutter_venetian, multiplicity: {param: venetian_windows}}
comfort: {enabled: true, function: shutter_venetian, multiplicity: {param: venetian_windows}}
- slot_id: presence
labels: {ru: датчик, en: sensor}
presets:
@@ -54,3 +67,10 @@ automation_intents:
description: "Recall living-room lighting scenes (movie/evening/bright)."
criticality: convenience
implementation: external
# Safety-related: wind/frost retracts the blinds. This protection MUST live in
# KNX (autonomous, network-independent), never in Home Assistant — the actuator
# listens on the shutter_venetian safety-lock input.
- intent: wind_frost_blind_protection
description: "Retract/lock blinds on high wind or frost (via the safety-lock input)."
criticality: safety_related
implementation: knx
+8 -3
View File
@@ -22,8 +22,9 @@ from nickol_knx_mcp.policy import check_policy, load_policy
# --------------------------------------------------------------------------- #
# Golden: a single bedroom (comfort) — exact address -> (name, dpt) in RU and EN.
# Hand-verified structure: 26 GAs = dimmer(5) + 2×switch(2) + 2×shutter(4)
# + climate(7) + presence(2).
# Hand-verified structure: 28 GAs = dimmer(5) + 2×switch(2) + 2×shutter(4)
# + climate(9: +actuating-value +its status) + presence(2). The opt-in
# venetian_blind slot has venetian_windows=0 by default, so it emits nothing here.
# --------------------------------------------------------------------------- #
GOLDEN_BEDROOM_RU = {
"1/0/1": ("Спальня основной свет вкл/выкл", "1.001"),
@@ -49,7 +50,9 @@ GOLDEN_BEDROOM_RU = {
"3/4/1": ("Спальня тёплый пол статус", "1.011"),
"3/4/2": ("Спальня тёплый пол уставка статус", "9.001"),
"3/4/3": ("Спальня тёплый пол режим статус", "20.102"),
"3/4/4": ("Спальня тёплый пол уровень клапана статус", "5.001"),
"3/5/1": ("Спальня тёплый пол температура факт", "9.001"),
"3/7/1": ("Спальня тёплый пол уровень клапана", "5.001"),
"4/0/1": ("Спальня датчик присутствие", "1.018"),
"4/1/1": ("Спальня датчик освещённость", "9.004"),
}
@@ -77,7 +80,9 @@ GOLDEN_BEDROOM_EN = {
"3/4/1": ("Bedroom floor heating status", "1.011"),
"3/4/2": ("Bedroom floor heating setpoint status", "9.001"),
"3/4/3": ("Bedroom floor heating mode status", "20.102"),
"3/4/4": ("Bedroom floor heating actuating value status", "5.001"),
"3/5/1": ("Bedroom floor heating actual temperature", "9.001"),
"3/7/1": ("Bedroom floor heating actuating value", "5.001"),
"4/0/1": ("Bedroom sensor occupancy", "1.018"),
"4/1/1": ("Bedroom sensor illuminance", "9.004"),
}
@@ -90,7 +95,7 @@ def _obj_map(house):
def test_templates_all_valid():
tmpls = rl.load_builtin_templates()
assert set(tmpls) == {"bedroom", "children", "living", "kitchen",
"bathroom", "corridor"}, sorted(tmpls)
"bathroom", "corridor", "central"}, sorted(tmpls)
for tid, t in tmpls.items():
v = rl.validate_room_template(t)
assert v["ok"], (tid, v["findings"])
+168
View File
@@ -0,0 +1,168 @@
"""Theben-benchmark corrections (see docs/roadmap/room-library/theben-benchmark/).
Covers:
(a) repair `_infer_dpt` — relative dimming (brighter/darker) -> 3.007, while an
explicit "Dimming value %"/"Brightness value" stays 5.001 (dogfood bug fix);
(b) the new/extended FUNCTION_OBJECTS (shutter_venetian, climate_floor valve,
ventilation, lighting_dali, multisensor_air, central_*) — GA counts, DPTs,
command/status pairing;
(c) the ventilation domain classifier (airing/проветривание/Lüftung -> hvac);
(d) end-to-end: a house using venetian blinds + central functions re-reads
through the standard loader with 0 errors / 0 warnings, and the wind/frost
safety-lock is an INFO (safety_input_no_status), never a missing-status.
DPT provenance: 3.007 = DPT_Control_Dimming (4-bit relative), 5.001 = Scaling %,
5.010 = 1-byte counter, 9.007 = humidity, 9.008 = CO₂ ppm, 20.102 = HVAC mode —
confirmed against the KNX DPT catalogue (XKNX/xknx via deepwiki).
"""
import os
import tempfile
from types import SimpleNamespace
from nickol_knx_mcp import room_library as rl
from nickol_knx_mcp.repair import _infer_dpt
from nickol_knx_mcp.project import load_project, _domain_from_text
from nickol_knx_mcp.analyze import (validate_naming, detect_missing_status,
detect_dpt_issues)
from nickol_knx_mcp.policy import check_policy, load_policy
# --------------------------------------------------------------------------- #
# (a) repair._infer_dpt — relative vs absolute dimming
# --------------------------------------------------------------------------- #
def _stub(name, kind="command", category="lighting", main=1):
return SimpleNamespace(name=name, kind=kind, category=category, main=main)
def test_infer_dpt_relative_dimming_is_3007():
# relative brighter/darker (EN/DE/RU) -> 3.007 DPT_Control_Dimming (was 1.001)
assert _infer_dpt(_stub("Brighter/ darker Light array 1")) == "3.007"
assert _infer_dpt(_stub("Heller / dunkler Wohnzimmer")) == "3.007"
assert _infer_dpt(_stub("Светлее/темнее гостиная")) == "3.007"
assert _infer_dpt(_stub("Ярче/тусклее спальня")) == "3.007"
def test_infer_dpt_explicit_value_stays_5001():
# explicit absolute value must NOT be flipped to relative — stays 5.001
assert _infer_dpt(_stub("Dimming value % Light array 1")) == "5.001"
assert _infer_dpt(_stub("Brightness value Kitchen")) == "5.001"
assert _infer_dpt(_stub("Значение яркости кухня")) == "5.001"
def test_infer_dpt_defaults_unchanged():
# a plain switch is still the safe 1.001 default (regression guard)
assert _infer_dpt(_stub("Спальня свет - Вкл/выкл")) == "1.001"
assert _infer_dpt(_stub("Hall switch")) == "1.001"
# --------------------------------------------------------------------------- #
# (b) new / extended FUNCTION_OBJECTS
# --------------------------------------------------------------------------- #
def test_function_objects_shutter_venetian():
sv = {o.role: o for o in rl.FUNCTION_OBJECTS["shutter_venetian"]}
assert len(rl.FUNCTION_OBJECTS["shutter_venetian"]) == 7 # shutter(4)+slat+slat_status+lock
assert (sv["slat"].dpt_main, sv["slat"].dpt_sub, sv["slat"].kind) == (5, 1, "command")
assert (sv["slat_status"].dpt_main, sv["slat_status"].dpt_sub, sv["slat_status"].kind) == (5, 1, "status")
assert (sv["safety_lock"].dpt_main, sv["safety_lock"].dpt_sub, sv["safety_lock"].kind) == (1, 1, "command")
# roller shutter is unchanged (backward compat)
assert len(rl.FUNCTION_OBJECTS["shutter"]) == 4
def test_function_objects_climate_floor_valve():
cf = {o.role: o for o in rl.FUNCTION_OBJECTS["climate_floor"]}
assert (cf["actuating_value"].dpt_main, cf["actuating_value"].dpt_sub,
cf["actuating_value"].kind) == (5, 1, "command")
assert (cf["actuating_value_status"].dpt_main, cf["actuating_value_status"].dpt_sub,
cf["actuating_value_status"].kind) == (5, 1, "status")
# the historical on/off + setpoint + mode + statuses + actual-temp remain
assert len(rl.FUNCTION_OBJECTS["climate_floor"]) == 9
def test_function_objects_ventilation():
v = {o.role: o for o in rl.FUNCTION_OBJECTS["ventilation"]}
assert (v["airing_stage"].dpt_main, v["airing_stage"].dpt_sub, v["airing_stage"].kind) == (5, 10, "command")
assert (v["airing_stage_status"].dpt_main, v["airing_stage_status"].dpt_sub) == (5, 10)
assert (v["co2"].dpt_main, v["co2"].dpt_sub, v["co2"].kind) == (9, 8, "sensor")
assert (v["humidity"].dpt_main, v["humidity"].dpt_sub, v["humidity"].kind) == (9, 7, "sensor")
def test_function_objects_dali_is_dimmer_clone():
dali = [(o.role, o.dpt_main, o.dpt_sub, o.kind) for o in rl.FUNCTION_OBJECTS["lighting_dali"]]
dim = [(o.role, o.dpt_main, o.dpt_sub, o.kind) for o in rl.FUNCTION_OBJECTS["lighting_dimmer"]]
assert dali == dim, "lighting_dali must be a 1:1 clone of lighting_dimmer"
assert rl.BOM_RECIPE["lighting_dali"] == "dali_gateway_group"
def test_function_objects_multisensor_and_central():
ms = rl.FUNCTION_OBJECTS["multisensor_air"]
assert {o.kind for o in ms} == {"sensor"} # self-reporting, no command -> no status pair
assert {(o.dpt_main, o.dpt_sub) for o in ms} == {(9, 1), (9, 7), (9, 8)}
assert rl.FUNCTION_OBJECTS["central_scene"][0].dpt_main == 18
assert rl.FUNCTION_OBJECTS["central_all_off"][0].dpt_main == 1
assert rl.FUNCTION_OBJECTS["central_all_on"][0].dpt_main == 1
# --------------------------------------------------------------------------- #
# (c) ventilation domain classifier (F5: "Airing step" was 'unknown')
# --------------------------------------------------------------------------- #
def test_ventilation_domain_classifier():
assert _domain_from_text("Airing step") == "hvac"
assert _domain_from_text("Проветривание кухня") == "hvac"
assert _domain_from_text("Lüftung Stufe 2") == "hvac"
assert _domain_from_text("Bad Lüften") == "hvac"
# regression: an existing lighting name is untouched by the new terms
assert _domain_from_text("Terrace light") == "lighting"
# --------------------------------------------------------------------------- #
# (d) end-to-end: venetian blinds + central functions -> lint-clean, safety-lock
# is INFO not a warning.
# --------------------------------------------------------------------------- #
def _reread(house):
data = rl.build_knxproj_bytes(house)
tf = tempfile.NamedTemporaryFile(suffix=".knxproj", delete=False)
try:
tf.write(data)
tf.close()
return load_project(tf.name)
finally:
os.unlink(tf.name)
def test_venetian_and_central_round_trip_clean():
rooms = [{"template": "living", "preset": "comfort",
"params": {"windows": 1, "venetian_windows": 2}},
{"template": "central", "preset": "comfort"}]
house = rl.resolve_house(rooms, rl.load_builtin_templates(), language="ru")
names = " ".join(o.name for o in house.objects)
assert "ламели" in names and "блокировка" in names, "venetian slat + safety-lock not emitted"
assert "сцена" in names and "всё выкл" in names, "central scene + all-off not emitted"
proj = _reread(house)
assert len(proj.gas) == len(house.objects)
findings = (validate_naming(proj) + detect_missing_status(proj) + detect_dpt_issues(proj)
+ check_policy(proj, load_policy(None))["findings"])
errors = [f for f in findings if f.get("severity") == "error"]
warnings = [f for f in findings if f.get("severity") == "warning"]
assert not errors, [(f["code"], f["address"]) for f in errors]
assert not warnings, [(f["code"], f["address"]) for f in warnings]
# the wind/frost safety-lock is a write-only input: INFO, never missing_status
codes = {(f["code"]) for f in detect_missing_status(proj)}
assert "safety_input_no_status" in codes
assert "missing_status_address" not in codes
def main():
for name, fn in sorted(globals().items()):
if name.startswith("test_") and callable(fn):
fn()
print(f" {name}: OK")
print("test_theben_corrections: OK — relative dimming -> 3.007 (abs value stays "
"5.001); shutter_venetian/valve/ventilation/dali/multisensor/central objects "
"verified; airing -> hvac; venetian+central house is lint-clean; safety-lock "
"is INFO not missing-status.")
if __name__ == "__main__":
main()