mirror of
https://github.com/NickoScope/nickol-knx-mcp.git
synced 2026-09-29 19:31:12 +02:00
fix(P5): dogfood round 2 — presence is a sensor, 9.001 soft, passive-range rule
- 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3c086fe228
commit
ccfedbdeca
+7
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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)"},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user