3 Commits

5 changed files with 140 additions and 32 deletions

View File

@@ -10,7 +10,7 @@ The integration should:
- Auto-select appropriate KNX DPTs and request only the group addresses needed. - Auto-select appropriate KNX DPTs and request only the group addresses needed.
- Ignore missing/empty group addresses without errors. - Ignore missing/empty group addresses without errors.
Target compatibility: Home Assistant 2025.12 with forward compatibility to 2026.2. Target compatibility: Home Assistant 2025.12 with forward compatibility to 2026.03.
Project rules: Project rules:
- Keep `README.md` updated for user-relevant changes after each new version. - Keep `README.md` updated for user-relevant changes after each new version.
@@ -18,7 +18,7 @@ Project rules:
- Releases are created only when explicitly requested. - Releases are created only when explicitly requested.
- Version number must match everywhere it is referenced (manifest, changelog, README, HACS if used). - Version number must match everywhere it is referenced (manifest, changelog, README, HACS if used).
## Current State (as of 2026-02-15) ## Current State (as of 2026-03-09)
Completed: Completed:
- Repo initialized with `main` branch and pushed to GitHub. - Repo initialized with `main` branch and pushed to GitHub.
- HACS metadata (`hacs.json`) and base integration scaffold created. - HACS metadata (`hacs.json`) and base integration scaffold created.
@@ -48,7 +48,9 @@ Completed:
- Relative color temperature control wired into light schema, UI order adjusted, and KNX color temperature types aligned. - Relative color temperature control wired into light schema, UI order adjusted, and KNX color temperature types aligned.
- Color temperature service calls now use mireds for better compatibility. - Color temperature service calls now use mireds for better compatibility.
- Relative dimming/color temperature decoding improved for control/stepcode payloads. - Relative dimming/color temperature decoding improved for control/stepcode payloads.
- Project version set to 0.0.30 and `CHANGELOG.md` maintained. - Relative dimming/CT now parse control strings from DPT 3.007 payloads and treat raw step values as percent steps (0/8 stop).
- Home Assistant 2026.03 compatibility fix: graceful fallback for removed `ATTR_COLOR_TEMP` import and color temperature service calls now use Kelvin.
- Project version set to 0.0.34 and `CHANGELOG.md` maintained.
Files created: Files created:
- `custom_components/ha_knx_bridge/__init__.py` - `custom_components/ha_knx_bridge/__init__.py`

View File

@@ -1,5 +1,18 @@
# Changelog # Changelog
## 0.0.34 - 2026-03-09
- Fix startup on Home Assistant 2026.03 by handling removed `ATTR_COLOR_TEMP` imports.
- Switch light color temperature service calls to `color_temp_kelvin`.
## 0.0.33 - 2026-02-15
- Parse control strings from DPT 3.007 payloads and use raw step values as percent steps (0/8 stop).
## 0.0.32 - 2026-02-15
- Treat DPT 3.007 step_code 0 as a start/stop toggle for relative dimming/CT and rely on raw payload parsing.
## 0.0.31 - 2026-02-15
- Treat DPT 3.007 step_code 0 as a single-step for relative dimming/CT and register control_dimming event types.
## 0.0.30 - 2026-02-15 ## 0.0.30 - 2026-02-15
- Improve relative dimming/color temperature decoding for control/stepcode payloads. - Improve relative dimming/color temperature decoding for control/stepcode payloads.

View File

@@ -10,7 +10,7 @@ Current minimal scope:
- Light port (KNX -> HA commands, HA state -> KNX) - Light port (KNX -> HA commands, HA state -> KNX)
## Requirements ## Requirements
- Home Assistant 2025.12 or newer (tested for compatibility with 2026.2). - Home Assistant 2025.12 or newer (tested for compatibility with 2026.03).
- The built-in KNX integration must be set up and running. - The built-in KNX integration must be set up and running.
## Install (HACS) ## Install (HACS)
@@ -77,7 +77,8 @@ Notes:
- Relative dimming (DPT 3.007) maps KNX step values (control/stepcode) to small `brightness_step_pct` changes in Home Assistant. - Relative dimming (DPT 3.007) maps KNX step values (control/stepcode) to small `brightness_step_pct` changes in Home Assistant.
- For relative dimming, the bridge repeats steps until a KNX stop telegram (0 or 8) is received. - For relative dimming, the bridge repeats steps until a KNX stop telegram (0 or 8) is received.
- Relative color temperature (DPT 3.007) adjusts Kelvin in the same start/stop pattern. - Relative color temperature (DPT 3.007) adjusts Kelvin in the same start/stop pattern.
- Color temperature mode must match the KNX telegram DPT: `relative` for 5.001, `absolute` for 7.600 (2-byte unsigned), `absolute_float` for DPT 9 (2-byte float). The bridge sends HA color temperature using `color_temp` (mireds) for maximum compatibility. - Step values `1..7` dim down, `9..15` dim up, and `0/8` stop; the bridge uses the raw step value as the percent step size.
- Color temperature mode must match the KNX telegram DPT: `relative` for 5.001, `absolute` for 7.600 (2-byte unsigned), `absolute_float` for DPT 9 (2-byte float). The bridge sends HA color temperature using `color_temp_kelvin`.
## Notes ## Notes
- For DPT 1.008 (Up/Down), the bridge treats `0 = Up/Open` and `1 = Down/Close`. - For DPT 1.008 (Up/Down), the bridge treats `0 = Up/Open` and `1 = Down/Close`.
@@ -112,5 +113,5 @@ Notes:
- Advanced DPT mapping options and inversion settings. - Advanced DPT mapping options and inversion settings.
## Versioning and Releases ## Versioning and Releases
- Current version: 0.0.30 - Current version: 0.0.34
- `CHANGELOG.md` lists versions with the newest entries at the top. - `CHANGELOG.md` lists versions with the newest entries at the top.

View File

@@ -8,7 +8,6 @@ from typing import Any
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
ATTR_COLOR_MODE, ATTR_COLOR_MODE,
ATTR_COLOR_TEMP,
ATTR_HS_COLOR, ATTR_HS_COLOR,
ATTR_RGB_COLOR, ATTR_RGB_COLOR,
ATTR_RGBW_COLOR, ATTR_RGBW_COLOR,
@@ -79,6 +78,11 @@ _LOGGER = logging.getLogger(__name__)
KNX_DOMAIN = "knx" KNX_DOMAIN = "knx"
try:
from homeassistant.components.light import ATTR_COLOR_TEMP
except ImportError: # pragma: no cover - fallback for newer HA
ATTR_COLOR_TEMP = "color_temp"
try: try:
from homeassistant.components.light import ATTR_COLOR_TEMP_KELVIN from homeassistant.components.light import ATTR_COLOR_TEMP_KELVIN
except ImportError: # pragma: no cover - fallback for older HA except ImportError: # pragma: no cover - fallback for older HA
@@ -678,13 +682,13 @@ class BridgeManager:
) )
self._register_knx_light_command( self._register_knx_light_command(
port.relative_color_temperature_address, port.relative_color_temperature_address,
None, "control_dimming",
port, port,
"relative_color_temperature", "relative_color_temperature",
) )
self._register_knx_light_command( self._register_knx_light_command(
port.relative_dimming_address, port.relative_dimming_address,
None, "control_dimming",
port, port,
"relative_dimming", "relative_dimming",
) )
@@ -1167,11 +1171,17 @@ class BridgeManager:
await self._call_light_service( await self._call_light_service(
port.entity_id, port.entity_id,
"turn_on", "turn_on",
{"color_temp": _kelvin_to_mireds(kelvin)}, {ATTR_COLOR_TEMP_KELVIN: int(round(kelvin))},
) )
return return
if action == "relative_dimming": if action == "relative_dimming":
dim_info = _extract_dimming_info(event)
if dim_info is not None:
control, step_code = dim_info
if step_code == 0:
self._stop_light_dimming(port.entity_id)
return
value = _extract_dimming_value(event) value = _extract_dimming_value(event)
if value is None: if value is None:
return return
@@ -1188,6 +1198,12 @@ class BridgeManager:
return return
if action == "relative_color_temperature": if action == "relative_color_temperature":
dim_info = _extract_dimming_info(event)
if dim_info is not None:
control, step_code = dim_info
if step_code == 0:
self._stop_light_ct_adjust(port.entity_id)
return
value = _extract_dimming_value(event) value = _extract_dimming_value(event)
if value is None: if value is None:
return return
@@ -1448,6 +1464,21 @@ class BridgeManager:
_runner() _runner()
) )
def _apply_light_dimming_step(
self, entity_id: str, direction: str, step_code: int
) -> None:
percent = _relative_dimming_percent(step_code)
if percent is None or percent <= 0:
return
async def _runner() -> None:
step = percent if direction == "up" else -percent
await self._call_light_service(
entity_id, "turn_on", {"brightness_step_pct": step}
)
self.hass.async_create_task(_runner())
def _stop_light_dimming(self, entity_id: str) -> None: def _stop_light_dimming(self, entity_id: str) -> None:
task = self._light_dimming_tasks.pop(entity_id, None) task = self._light_dimming_tasks.pop(entity_id, None)
if task is not None: if task is not None:
@@ -1477,7 +1508,7 @@ class BridgeManager:
await self._call_light_service( await self._call_light_service(
port.entity_id, port.entity_id,
"turn_on", "turn_on",
{"color_temp": _kelvin_to_mireds(next_kelvin)}, {ATTR_COLOR_TEMP_KELVIN: int(round(next_kelvin))},
) )
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
@@ -1485,6 +1516,36 @@ class BridgeManager:
_runner() _runner()
) )
def _apply_light_ct_step_once(
self, port: LightPort, direction: str, step_code: int
) -> None:
percent = _relative_dimming_percent(step_code)
if percent is None or percent <= 0:
return
async def _runner() -> None:
current_kelvin = _current_color_temp_kelvin(self.hass, port)
if current_kelvin is None:
current_kelvin = _light_min_kelvin(port)
delta = (_light_max_kelvin(port) - _light_min_kelvin(port)) * (
percent / 100
)
if direction == "up":
next_kelvin = current_kelvin + delta
else:
next_kelvin = current_kelvin - delta
next_kelvin = min(
max(next_kelvin, _light_min_kelvin(port)),
_light_max_kelvin(port),
)
await self._call_light_service(
port.entity_id,
"turn_on",
{ATTR_COLOR_TEMP_KELVIN: int(round(next_kelvin))},
)
self.hass.async_create_task(_runner())
def _stop_light_ct_adjust(self, entity_id: str) -> None: def _stop_light_ct_adjust(self, entity_id: str) -> None:
task = self._light_ct_tasks.pop(entity_id, None) task = self._light_ct_tasks.pop(entity_id, None)
if task is not None: if task is not None:
@@ -1512,27 +1573,56 @@ def _extract_event_value(event: Event) -> int | None:
def _extract_dimming_value(event: Event) -> int | None: def _extract_dimming_value(event: Event) -> int | None:
info = _extract_dimming_info(event)
if info is not None:
control, step_code = info
return ((1 if control else 0) << 3) | (step_code & 0x07)
return _extract_event_value(event)
def _extract_dimming_info(event: Event) -> tuple[bool, int] | None:
if "value" in event.data: if "value" in event.data:
value = event.data["value"] value = event.data["value"]
if isinstance(value, dict): if isinstance(value, dict):
control = value.get("control") control = value.get("control")
step = value.get("stepcode", value.get("step_code")) step = value.get("stepcode", value.get("step_code"))
if control is not None and step is not None: if control is not None and step is not None:
control_bit = _parse_control_value(control)
if control_bit is None:
return None
try: try:
control_bit = 1 if int(control) else 0 return control_bit, int(step) & 0x07
step_code = int(step) & 0x07
return (control_bit << 3) | step_code
except (TypeError, ValueError): except (TypeError, ValueError):
pass return None
if isinstance(value, (int, float)):
raw = int(value)
return bool(raw & 0x08), raw & 0x07
if isinstance(value, (list, tuple)) and len(value) >= 2:
try:
return bool(int(value[0])), int(value[1]) & 0x07
except (TypeError, ValueError):
return None
data = event.data.get("data") data = event.data.get("data")
if isinstance(data, (list, tuple)) and len(data) >= 2: if isinstance(data, (list, tuple)) and len(data) >= 2:
try: try:
control_bit = 1 if int(data[0]) else 0 return bool(int(data[0])), int(data[1]) & 0x07
step_code = int(data[1]) & 0x07
return (control_bit << 3) | step_code
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
return _extract_event_value(event) return None
def _parse_control_value(value: Any) -> bool | None:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(int(value))
if isinstance(value, str):
text = value.strip().lower()
if text in ("increase", "up", "raise", "1", "true", "on"):
return True
if text in ("decrease", "down", "lower", "0", "false", "off"):
return False
return None
def _clean_address(address: Any) -> str | None: def _clean_address(address: Any) -> str | None:
@@ -1611,12 +1701,6 @@ def _percent_to_brightness(percent: int) -> int:
return int(round(percent / 100 * 255)) return int(round(percent / 100 * 255))
def _kelvin_to_mireds(kelvin: float) -> int:
if kelvin <= 0:
return 0
return int(round(1000000 / kelvin))
def _mireds_to_kelvin(mireds: float) -> float: def _mireds_to_kelvin(mireds: float) -> float:
if mireds <= 0: if mireds <= 0:
return 0 return 0
@@ -1808,10 +1892,16 @@ def _relative_dimming_step(value: int) -> tuple[str, int] | None:
return None return None
if value <= 7: if value <= 7:
direction = "down" direction = "down"
step = value percent = value
else: else:
direction = "up" direction = "up"
step = value - 8 percent = value
if percent <= 0:
return None
return direction, percent
def _relative_dimming_percent(step_code: int) -> int | None:
percent_map = { percent_map = {
1: 10, 1: 10,
2: 8, 2: 8,
@@ -1821,10 +1911,12 @@ def _relative_dimming_step(value: int) -> tuple[str, int] | None:
6: 2, 6: 2,
7: 1, 7: 1,
} }
percent = percent_map.get(step) step = int(step_code)
if percent is None: if step <= 0:
return None step = 1
return direction, percent if step > 7:
step = 7
return percent_map.get(step)
def _map_scalar_value(value: Any) -> int | None: def _map_scalar_value(value: Any) -> int | None:

View File

@@ -1,7 +1,7 @@
{ {
"domain": "ha_knx_bridge", "domain": "ha_knx_bridge",
"name": "HA KNX Bridge", "name": "HA KNX Bridge",
"version": "0.0.30", "version": "0.0.34",
"config_flow": true, "config_flow": true,
"documentation": "https://github.com/bahmcloud/HA-KNX-Bridge", "documentation": "https://github.com/bahmcloud/HA-KNX-Bridge",
"issue_tracker": "https://github.com/bahmcloud/HA-KNX-Bridge/issues", "issue_tracker": "https://github.com/bahmcloud/HA-KNX-Bridge/issues",