From ccfedbdeca90686fcba1083d758a4d5b21cc4b2e Mon Sep 17 00:00:00 2001 From: Nikolay1 Date: Wed, 15 Jul 2026 11:33:20 +0200 Subject: [PATCH] =?UTF-8?q?fix(P5):=20dogfood=20round=202=20=E2=80=94=20pr?= =?UTF-8?q?esence=20is=20a=20sensor,=209.001=20soft,=20passive-range=20rul?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DPT 1.018 occupancy: category sensor (was diagnostics; the name always says sensor and the table fought it into a fake conflict). - 9.001 temperature is SOFT: room temperature defaults to hvac, but a weather- named temperature re-domains to sensor without a fabricated conflict. - illuminance terms: "освещённость"/"illuminance" are sensor words and must not be swallowed by the greedy lighting prefix "освещ". - passive-range rule: a measurement inside a Sensors/Energy/Diagnostics main takes that main's domain; a COMMAND is never retyped by a passive range. Deliberately NOT tuned further: remaining real-project findings (Minsk 10/685, Razdory 75/3646, demo 2/239) are defensible mixed-main observations a reviewer should see once — zeroing them out would overfit one integrator's idiosyncrasy. 13/13 tests green. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 8 +++++++- nickol_knx_mcp/dpt_map.py | 2 +- nickol_knx_mcp/project.py | 19 ++++++++++++++----- tests/test_domain_classifier.py | 8 +++++++- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 751f15b..d2112a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,7 +48,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 **DPT 1.010 start/stop is soft** (ventilation timers use it too, not just shutters); terms learned: Cyrillic **а/с**, **сплит**, **вытяжк**, **яркост** (RU only — EN "brightness" also means weather lux), meteo/weather (sensor now outranks hvac so "Метеостанция - Температура" is a sensor, not an - HVAC actuator). Real-project noise after the round: Minsk 1 outlier / 685 GAs, demo 2 / 239. + HVAC actuator). Round 2: presence **1.018 is a sensor** (was diagnostics — its name always says so + and the table fought it); **9.001 temperature is soft** (room temp defaults to hvac, a weather-named + temp re-domains to sensor without a fake conflict); **illuminance** terms ("освещённость" ≠ + "освещение"); and a **passive-range rule** — a measurement inside a Sensors/Energy/Diagnostics main + takes that domain, while a command is never retyped by a passive range. Real-project noise after + both rounds: demo 2 outliers / 239 GAs (the two central macros), Minsk 10 / 685 (all defensible — + a genuinely mixed water/energy main), Razdory 75 / 3646 (~2%). ### Added diff --git a/nickol_knx_mcp/dpt_map.py b/nickol_knx_mcp/dpt_map.py index 99f15ce..4b97072 100644 --- a/nickol_knx_mcp/dpt_map.py +++ b/nickol_knx_mcp/dpt_map.py @@ -65,7 +65,7 @@ _EXACT: dict[tuple[int, Optional[int]], DptInfo] = { (1, 9): {"category": CATEGORY_UNKNOWN, "kind": KIND_SENSOR, "ha_platform": "binary_sensor", "value_type": None, "label": "Open/Close"}, (1, 10): {"category": CATEGORY_SHUTTER, "kind": KIND_COMMAND, "ha_platform": "cover", "value_type": None, "label": "Start/Stop"}, (1, 11): {"category": CATEGORY_LIGHTING, "kind": KIND_STATUS, "ha_platform": "binary_sensor", "value_type": None, "label": "State (status)"}, - (1, 18): {"category": CATEGORY_DIAG, "kind": KIND_SENSOR, "ha_platform": "binary_sensor", "value_type": None, "label": "Occupancy"}, + (1, 18): {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "binary_sensor", "value_type": None, "label": "Occupancy"}, (1, 19): {"category": CATEGORY_DIAG, "kind": KIND_SENSOR, "ha_platform": "binary_sensor", "value_type": None, "label": "Window/Door"}, (3, 7): {"category": CATEGORY_LIGHTING, "kind": KIND_COMMAND, "ha_platform": "light", "value_type": None, "label": "Dimming control (relative)"}, (3, 8): {"category": CATEGORY_SHUTTER, "kind": KIND_COMMAND, "ha_platform": "cover", "value_type": None, "label": "Blinds control (relative)"}, diff --git a/nickol_knx_mcp/project.py b/nickol_knx_mcp/project.py index 8ca3931..31b5d6b 100644 --- a/nickol_knx_mcp/project.py +++ b/nickol_knx_mcp/project.py @@ -50,8 +50,9 @@ _DOMAIN_TERMS: dict[str, list[str]] = { # датчик, motion) is a sensor even when it measures temperature — otherwise # "Метеостанция - Температура" lands in hvac via the "температур" term. "sensor": ["sensor", "motion", "presence", "occupancy", "brightness sens", "lux", - "humidity", "weather", "meteo", "датчик", "движени", "присутств", "влажн", - "люкс", "метео", "погод"], + "humidity", "weather", "meteo", "illuminance", "датчик", "движени", + "присутств", "влажн", "люкс", "метео", "погод", + "освещённост", "освещенност"], # illuminance ≠ "освещение" (checked first) "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", @@ -111,7 +112,9 @@ def _refine_category(name: str, current: str) -> str: # a pump or an AC; a 5.001 scaling is a brightness OR a shutter position. For soft # DPTs a contradicting name is NOT a conflict (it disambiguates), unlike a strong, # domain-encoding DPT (a shutter 1.008, an HVAC-mode 20.102, a temperature 9.001). -_SOFT_DPT = {(1, 1), (1, 11), (1, 10), (5, 1)} # 1.010 start/stop is generic (vent timers too) +# 1.010 start/stop is generic (vent timers too); 9.001 temperature is hvac by +# default but a weather/sensor name legitimately re-domains it without conflict. +_SOFT_DPT = {(1, 1), (1, 11), (1, 10), (5, 1), (9, 1)} # The subset that is domain-AGNOSTIC end to end: with no name and no range signal # these stay 'unknown' — we never infer 'lighting' from a bare 1-bit DPT. Other # soft DPTs (5.001) keep their sensible default (brightness) when unsignalled. @@ -119,7 +122,8 @@ _AGNOSTIC_NO_DEFAULT = {(1, 1), (1, 11)} def _classify_category(name: str, main_name: str, middle_name: str, - main: Optional[int], sub: Optional[int], dpt_cat: str) -> str: + main: Optional[int], sub: Optional[int], dpt_cat: str, + dpt_kind: str = "unknown") -> str: """Combine signals into a domain: explicit name > strong DPT > range context. * an explicit name domain wins — unless it *contradicts* a strong (domain- @@ -149,6 +153,11 @@ def _classify_category(name: str, main_name: str, middle_name: str, range_dom = _domain_from_text(main_name) or _domain_from_text(middle_name) if range_dom in _RANGE_ASSIGNABLE: return range_dom + # Passive mains constrain passive GAs: a measurement sitting in a Sensors/ + # Energy/Diagnostics range is that domain (a 9.001 in "Sensors" is a sensor, + # not a default hvac) — but a COMMAND is never retyped by a passive range. + if dpt_kind != "command" and range_dom in ("sensor", "energy", "diagnostics"): + return range_dom if (main, sub) in _AGNOSTIC_NO_DEFAULT: return CATEGORY_UNKNOWN_STR return dpt_cat @@ -289,7 +298,7 @@ def build_loaded_from_raw(raw: KNXProject, path: str) -> LoadedProject: # not the DPT alone — a 1-bit switch is domain-agnostic, so an "AC on/off" # is HVAC, not lighting, and a truly context-less switch is 'unknown'. category = _classify_category(ga.get("name", ""), main_name, middle_name, - main, sub, info["category"]) + main, sub, info["category"], info["kind"]) ha_platform = info["ha_platform"] # If the name says shutter but DPT mapped it to light (5.001/1.001), # correct the HA platform so the generator builds a cover, not a light. diff --git a/tests/test_domain_classifier.py b/tests/test_domain_classifier.py index fd63da3..155fe9e 100644 --- a/tests/test_domain_classifier.py +++ b/tests/test_domain_classifier.py @@ -49,7 +49,13 @@ def main(): # --- strong DPT contradicted by an explicit name -> honest unknown (a conflict) --- assert _classify_category("Bathroom fan", "", "", 3, 7, "lighting") == "unknown" # dimming vs fan - assert _classify_category("Foyer blind", "", "", 9, 1, "hvac") == "unknown" # temp DPT vs blind + assert _classify_category("Foyer blind", "", "", 9, 4, "sensor") == "unknown" # lux DPT vs blind + # 9.001 temperature is SOFT: a weather-named temp re-domains to sensor without conflict + assert _classify_category("Weather outdoor temperature", "", "", 9, 1, "hvac") == "sensor" + # a measurement in a passive (Sensors) main takes the range's domain... + assert _classify_category("Gostinaya temperatura", "Sensors", "", 9, 1, "hvac", "sensor") == "sensor" + # ...but a passive range never retypes a COMMAND + assert _classify_category("Kanal 7", "Sensors", "", 1, 1, "lighting", "command") == "unknown" # ...but agreement keeps the domain assert _classify_category("Living room dimmer", "", "", 3, 7, "lighting") == "lighting"