Files
Nikolay Miroshnichenko 264767f91f ci: ruff + mypy gates, Python 3.13/3.14 in the matrix, Dependabot
The CI ran tests only. Added a lint job with two narrow gates:

ruff with select = E9,F — syntax errors and pyflakes (undefined names, unused
imports, broken f-strings). Style rules are deliberately off: the full default
set flagged 202 things, almost all cosmetic, and a gate nobody reads is not a
gate. Fixed the 9 findings it did have (8 unused imports, 1 empty f-string).

mypy, clean at 13 errors fixed. None of the 13 was a live bug, so every fix is
an annotation or a local rename: the two accumulators in advanced, the block map
in appprog_parser, a reused loop name in project and in repair, a TypedDict lost
through dict(), a mixed-type summary dict in handover. Two are worth naming.
_CH_TOKEN_RE now captures the letter prefix directly instead of re-matching its
own capture, which removes an unguarded .group() on a possibly-None match; the
regex accepts exactly the same strings. repair's status-DPT lookup now spells out
the None case, which is what dict.get already did since None is not a key there.

Verified the fixes moved nothing: the full suite passes, and the corpus guard
reports no drift across all six real projects, the 3646-GA one included.

Matrix extended to 3.13 and 3.14; both were smoke-tested locally first.
Dependabot config for pip and github-actions, weekly and grouped so a quiet week
is one PR. Dependabot security updates and CodeQL default setup enabled in the
repository settings.
2026-09-12 21:36:23 +02:00

142 lines
11 KiB
Python

"""DPT classification and Home Assistant KNX platform mapping.
Central knowledge base translating a KNX Datapoint Type into:
* a functional *category* (lighting / shutter / hvac / sensor / scene / energy / diagnostics)
* a *kind* (command vs status vs sensor)
* a suggested Home Assistant KNX platform + value_type
Everything here is heuristic but conservative: when in doubt we mark the
datapoint for manual review rather than guessing an entity wrong.
"""
from __future__ import annotations
from typing import Optional, TypedDict
class DptInfo(TypedDict):
category: str # lighting|shutter|hvac|sensor|scene|energy|diagnostics|unknown
kind: str # command|status|sensor|unknown
ha_platform: str # switch|light|cover|sensor|binary_sensor|climate|number|scene|button|unknown
value_type: Optional[str] # HA value_type for sensor/number, None otherwise
label: str # human-readable description
CATEGORY_LIGHTING = "lighting"
CATEGORY_SHUTTER = "shutter"
CATEGORY_HVAC = "hvac"
CATEGORY_SENSOR = "sensor"
CATEGORY_SCENE = "scene"
CATEGORY_ENERGY = "energy"
CATEGORY_DIAG = "diagnostics"
CATEGORY_UNKNOWN = "unknown"
KIND_COMMAND = "command"
KIND_STATUS = "status"
KIND_SENSOR = "sensor"
KIND_UNKNOWN = "unknown"
def dpt_key(main: Optional[int], sub: Optional[int]) -> str:
"""Return a normalized 'main.sub' / 'main' string, '' if no DPT."""
if main is None:
return ""
if sub is None:
return str(main)
return f"{main}.{sub:03d}"
def dpt_ets_token(main: Optional[int], sub: Optional[int]) -> str:
"""ETS GA-export 'DPTs' attribute token, e.g. DPST-1-1 or DPT-1."""
if main is None:
return ""
if sub is None:
return f"DPT-{main}"
return f"DPST-{main}-{sub}"
# Exact (main, sub) overrides. None sub = applies to whole main group as fallback.
_EXACT: dict[tuple[int, Optional[int]], DptInfo] = {
(1, 1): {"category": CATEGORY_LIGHTING, "kind": KIND_COMMAND, "ha_platform": "switch", "value_type": None, "label": "Switch on/off"},
(1, 2): {"category": CATEGORY_UNKNOWN, "kind": KIND_COMMAND, "ha_platform": "switch", "value_type": None, "label": "Boolean"},
(1, 3): {"category": CATEGORY_UNKNOWN, "kind": KIND_COMMAND, "ha_platform": "switch", "value_type": None, "label": "Enable"},
(1, 5): {"category": CATEGORY_DIAG, "kind": KIND_SENSOR, "ha_platform": "binary_sensor", "value_type": None, "label": "Alarm"},
(1, 8): {"category": CATEGORY_SHUTTER, "kind": KIND_COMMAND, "ha_platform": "cover", "value_type": None, "label": "Up/Down"},
(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_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)"},
(5, 1): {"category": CATEGORY_LIGHTING, "kind": KIND_COMMAND, "ha_platform": "light", "value_type": "percent", "label": "Scaling 0-100% (brightness/position)"},
(5, 3): {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "angle", "label": "Angle 0-360°"},
(5, 10): {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "pulse", "label": "Counter pulses"},
(10, 1): {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "datetime", "value_type": "time", "label": "Time of day"},
(11, 1): {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "datetime", "value_type": "date", "label": "Date"},
(16, 0): {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "text", "value_type": "string", "label": "Character string (ASCII)"},
(16, 1): {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "text", "value_type": "string", "label": "Character string (ISO-8859-1)"},
(19, 1): {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "datetime", "value_type": "datetime", "label": "Date and time"},
(7, 12): {"category": CATEGORY_ENERGY, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "electric_current", "label": "Current (mA)"},
(7, 13): {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "illuminance", "label": "Brightness (lux)"},
(9, 1): {"category": CATEGORY_HVAC, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "temperature", "label": "Temperature (°C)"},
(9, 4): {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "illuminance", "label": "Illuminance (lux)"},
(9, 5): {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "wind_speed_ms", "label": "Wind speed (m/s)"},
(9, 7): {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "humidity", "label": "Humidity (%)"},
(9, 8): {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "ppm", "label": "Air quality (ppm)"},
(9, 20): {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "voltage", "label": "Voltage (mV)"},
(9, 21): {"category": CATEGORY_ENERGY, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "current", "label": "Current (mA)"},
(12, 1): {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "pulse_4_byte", "label": "Counter (4-byte unsigned)"},
(13, 10): {"category": CATEGORY_ENERGY, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "active_energy", "label": "Active energy (Wh)"},
(13, 13): {"category": CATEGORY_ENERGY, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "active_energy_kwh", "label": "Active energy (kWh)"},
(14, 19): {"category": CATEGORY_ENERGY, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "electric_current", "label": "Electric current (A)"},
(14, 27): {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "temperature", "label": "Temperature (K → °C)"},
(14, 56): {"category": CATEGORY_ENERGY, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "power", "label": "Power (W)"},
(17, 1): {"category": CATEGORY_SCENE, "kind": KIND_COMMAND, "ha_platform": "scene", "value_type": None, "label": "Scene number"},
(18, 1): {"category": CATEGORY_SCENE, "kind": KIND_COMMAND, "ha_platform": "scene", "value_type": None, "label": "Scene control"},
(20, 102): {"category": CATEGORY_HVAC, "kind": KIND_COMMAND, "ha_platform": "climate", "value_type": None, "label": "HVAC operation mode (comfort/standby/economy/frost)"},
(20, 105): {"category": CATEGORY_HVAC, "kind": KIND_COMMAND, "ha_platform": "climate", "value_type": None, "label": "HVAC controller mode (auto/heat/cool)"},
(1, 100): {"category": CATEGORY_HVAC, "kind": KIND_COMMAND, "ha_platform": "climate", "value_type": None, "label": "Heat/Cool"},
# Colour control — assembled into a light entity by the HA generator.
(7, 600): {"category": CATEGORY_LIGHTING, "kind": KIND_COMMAND, "ha_platform": "light", "value_type": None, "label": "Colour temperature (absolute K)"},
(232, 600): {"category": CATEGORY_LIGHTING, "kind": KIND_COMMAND, "ha_platform": "light", "value_type": None, "label": "RGB colour"},
(242, 600): {"category": CATEGORY_LIGHTING, "kind": KIND_COMMAND, "ha_platform": "light", "value_type": None, "label": "xyY colour"},
(251, 600): {"category": CATEGORY_LIGHTING, "kind": KIND_COMMAND, "ha_platform": "light", "value_type": None, "label": "RGBW colour"},
}
# Whole-main-group fallbacks when an exact (main, sub) is not known.
_MAIN_FALLBACK: dict[int, DptInfo] = {
1: {"category": CATEGORY_UNKNOWN, "kind": KIND_COMMAND, "ha_platform": "switch", "value_type": None, "label": "1-bit boolean"},
3: {"category": CATEGORY_LIGHTING, "kind": KIND_COMMAND, "ha_platform": "light", "value_type": None, "label": "4-bit relative control"},
5: {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "1byte_unsigned", "label": "1-byte unsigned"},
6: {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "1byte_signed", "label": "1-byte signed"},
7: {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "2byte_unsigned", "label": "2-byte unsigned"},
8: {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "2byte_signed", "label": "2-byte signed"},
9: {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "2byte_float", "label": "2-byte float"},
12: {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "4byte_unsigned", "label": "4-byte unsigned"},
13: {"category": CATEGORY_ENERGY, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "4byte_signed", "label": "4-byte signed"},
14: {"category": CATEGORY_SENSOR, "kind": KIND_SENSOR, "ha_platform": "sensor", "value_type": "4byte_float", "label": "4-byte float"},
17: {"category": CATEGORY_SCENE, "kind": KIND_COMMAND, "ha_platform": "scene", "value_type": None, "label": "Scene number"},
18: {"category": CATEGORY_SCENE, "kind": KIND_COMMAND, "ha_platform": "scene", "value_type": None, "label": "Scene control"},
20: {"category": CATEGORY_HVAC, "kind": KIND_COMMAND, "ha_platform": "climate", "value_type": None, "label": "1-byte HVAC enum"},
}
def is_exact_dpt(main: Optional[int], sub: Optional[int]) -> bool:
"""True when (main, sub) has an exact table entry — i.e. the category came
from the specific DPT, not from a whole-main-group fallback guess."""
return (main, sub) in _EXACT
def classify_dpt(main: Optional[int], sub: Optional[int]) -> DptInfo:
"""Classify a DPT into category / kind / HA platform."""
if main is None:
return {"category": CATEGORY_UNKNOWN, "kind": KIND_UNKNOWN,
"ha_platform": "unknown", "value_type": None, "label": "No DPT assigned"}
if (main, sub) in _EXACT:
return _EXACT[(main, sub)].copy() # copy
if main in _MAIN_FALLBACK:
return _MAIN_FALLBACK[main].copy()
return {"category": CATEGORY_UNKNOWN, "kind": KIND_UNKNOWN,
"ha_platform": "unknown", "value_type": None, "label": f"DPT {main}"}