diff --git a/nickol_knx_mcp/analyze.py b/nickol_knx_mcp/analyze.py index d1795f2..5814aec 100644 --- a/nickol_knx_mcp/analyze.py +++ b/nickol_knx_mcp/analyze.py @@ -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) diff --git a/nickol_knx_mcp/project.py b/nickol_knx_mcp/project.py index e0f5aeb..dafa9cd 100644 --- a/nickol_knx_mcp/project.py +++ b/nickol_knx_mcp/project.py @@ -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 diff --git a/nickol_knx_mcp/repair.py b/nickol_knx_mcp/repair.py index 9c45e69..ad07fca 100644 --- a/nickol_knx_mcp/repair.py +++ b/nickol_knx_mcp/repair.py @@ -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 diff --git a/nickol_knx_mcp/room_library.py b/nickol_knx_mcp/room_library.py index 9789955..50ec18d 100644 --- a/nickol_knx_mcp/room_library.py +++ b/nickol_knx_mcp/room_library.py @@ -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", } diff --git a/nickol_knx_mcp/room_templates/SCHEMA.md b/nickol_knx_mcp/room_templates/SCHEMA.md index cff8613..18b9510 100644 --- a/nickol_knx_mcp/room_templates/SCHEMA.md +++ b/nickol_knx_mcp/room_templates/SCHEMA.md @@ -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 diff --git a/nickol_knx_mcp/room_templates/bathroom.yaml b/nickol_knx_mcp/room_templates/bathroom.yaml index 4b8886e..08567d8 100644 --- a/nickol_knx_mcp/room_templates/bathroom.yaml +++ b/nickol_knx_mcp/room_templates/bathroom.yaml @@ -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." diff --git a/nickol_knx_mcp/room_templates/bedroom.yaml b/nickol_knx_mcp/room_templates/bedroom.yaml index 5942ca3..80f3279 100644 --- a/nickol_knx_mcp/room_templates/bedroom.yaml +++ b/nickol_knx_mcp/room_templates/bedroom.yaml @@ -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 diff --git a/nickol_knx_mcp/room_templates/central.yaml b/nickol_knx_mcp/room_templates/central.yaml new file mode 100644 index 0000000..fd8f6f2 --- /dev/null +++ b/nickol_knx_mcp/room_templates/central.yaml @@ -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 diff --git a/nickol_knx_mcp/room_templates/kitchen.yaml b/nickol_knx_mcp/room_templates/kitchen.yaml index a2027e5..82c3e2c 100644 --- a/nickol_knx_mcp/room_templates/kitchen.yaml +++ b/nickol_knx_mcp/room_templates/kitchen.yaml @@ -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." diff --git a/nickol_knx_mcp/room_templates/living.yaml b/nickol_knx_mcp/room_templates/living.yaml index df9266a..d997dae 100644 --- a/nickol_knx_mcp/room_templates/living.yaml +++ b/nickol_knx_mcp/room_templates/living.yaml @@ -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 diff --git a/tests/test_room_library.py b/tests/test_room_library.py index 4860634..ea7afc3 100644 --- a/tests/test_room_library.py +++ b/tests/test_room_library.py @@ -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"]) diff --git a/tests/test_theben_corrections.py b/tests/test_theben_corrections.py new file mode 100644 index 0000000..8be4f91 --- /dev/null +++ b/tests/test_theben_corrections.py @@ -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()