Add KNX address validation and bcs metadata

This commit is contained in:
2026-02-13 11:54:14 +01:00
parent 1d9a2c9c27
commit b733f9d62d
6 changed files with 182 additions and 42 deletions

View File

@@ -1,5 +1,10 @@
# Changelog
## 0.0.2 - 2026-02-13
- Validate and normalize KNX group addresses in subentry config flows.
- Add options flow skeleton for future settings.
- Add `bcs.yaml` metadata for BCS store listing.
## 0.0.1 - 2026-02-13
- Initial HACS-ready scaffold with config flow and subentries for binary sensor and cover.
- KNX bridge logic for basic send/receive mappings.

View File

@@ -35,6 +35,7 @@ Current minimal scope:
- `angle_state_address` (DPT 5.001): KNX group address that receives HA tilt updates.
If a group address is left empty, it is ignored.
Group address format must be `X/Y/Z` (0-31/0-7/0-255) or `X/Y` (0-31/0-2047).
## Notes
- For DPT 1.008 (Up/Down), the bridge treats `0 = Up/Open` and `1 = Down/Close`.
@@ -46,6 +47,6 @@ If a group address is left empty, it is ignored.
- Advanced DPT mapping options and inversion settings.
## Versioning and Releases
- Current version: 0.0.1
- Current version: 0.0.2
- `CHANGELOG.md` lists versions with the newest entries at the top.
- Release creation is manual and only done when explicitly requested.

21
bcs.yaml Normal file
View File

@@ -0,0 +1,21 @@
name: HA KNX Bridge
description: >
Home Assistant custom integration that mirrors Home Assistant entities
to KNX group addresses and accepts KNX actions to control Home Assistant
entities. It reuses an existing Home Assistant KNX integration and
provides per-entity "Ports" with automatic DPT selection and UI setup
via config flow.
category: Integrations
author: Bahmcloud
maintainer: Bahmcloud
domains:
- ha_knx_bridge
min_ha_version: "2025.12.0"
homepage: https://github.com/bahmcloud/HA-KNX-Bridge
issues: https://github.com/bahmcloud/HA-KNX-Bridge/issues
source: https://github.com/bahmcloud/HA-KNX-Bridge

View File

@@ -54,6 +54,11 @@ class HAKnxBridgeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
)
return self.async_show_form(step_id="user", data_schema=schema)
@staticmethod
@callback
def async_get_options_flow(config_entry):
return HAKnxBridgeOptionsFlow(config_entry)
@staticmethod
@callback
def async_get_supported_subentry_types(config_entry):
@@ -67,25 +72,34 @@ class HAKnxBridgeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
}
class HAKnxBridgeOptionsFlow(config_entries.OptionsFlow):
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
self._config_entry = config_entry
async def async_step_init(self, user_input: dict | None = None):
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
schema = vol.Schema({})
return self.async_show_form(step_id="init", data_schema=schema)
class BinarySensorPortSubentryFlow(config_entries.ConfigSubentryFlow):
VERSION = 1
async def async_step_user(self, user_input: dict | None = None):
if user_input is not None:
user_input, errors = _validate_knx_addresses(
user_input, [CONF_STATE_ADDRESS]
)
if errors:
return self.async_show_form(
step_id="user", data_schema=_binary_sensor_schema(), errors=errors
)
title = _entity_title(self.hass, user_input[CONF_ENTITY_ID])
return self.async_create_entry(title=title, data=user_input)
schema = vol.Schema(
{
vol.Required(CONF_ENTITY_ID): selector.EntitySelector(
selector.EntitySelectorConfig(domain=["binary_sensor"])
),
vol.Optional(CONF_STATE_ADDRESS): selector.TextSelector(
selector.TextSelectorConfig(type="text")
),
}
)
return self.async_show_form(step_id="user", data_schema=schema)
return self.async_show_form(step_id="user", data_schema=_binary_sensor_schema())
class CoverPortSubentryFlow(config_entries.ConfigSubentryFlow):
@@ -93,38 +107,26 @@ class CoverPortSubentryFlow(config_entries.ConfigSubentryFlow):
async def async_step_user(self, user_input: dict | None = None):
if user_input is not None:
user_input, errors = _validate_knx_addresses(
user_input,
[
CONF_MOVE_LONG_ADDRESS,
CONF_MOVE_SHORT_ADDRESS,
CONF_STOP_ADDRESS,
CONF_POSITION_ADDRESS,
CONF_POSITION_STATE_ADDRESS,
CONF_ANGLE_ADDRESS,
CONF_ANGLE_STATE_ADDRESS,
],
)
if errors:
return self.async_show_form(
step_id="user", data_schema=_cover_schema(), errors=errors
)
title = _entity_title(self.hass, user_input[CONF_ENTITY_ID])
return self.async_create_entry(title=title, data=user_input)
schema = vol.Schema(
{
vol.Required(CONF_ENTITY_ID): selector.EntitySelector(
selector.EntitySelectorConfig(domain=["cover"])
),
vol.Optional(CONF_MOVE_LONG_ADDRESS): selector.TextSelector(
selector.TextSelectorConfig(type="text")
),
vol.Optional(CONF_MOVE_SHORT_ADDRESS): selector.TextSelector(
selector.TextSelectorConfig(type="text")
),
vol.Optional(CONF_STOP_ADDRESS): selector.TextSelector(
selector.TextSelectorConfig(type="text")
),
vol.Optional(CONF_POSITION_ADDRESS): selector.TextSelector(
selector.TextSelectorConfig(type="text")
),
vol.Optional(CONF_POSITION_STATE_ADDRESS): selector.TextSelector(
selector.TextSelectorConfig(type="text")
),
vol.Optional(CONF_ANGLE_ADDRESS): selector.TextSelector(
selector.TextSelectorConfig(type="text")
),
vol.Optional(CONF_ANGLE_STATE_ADDRESS): selector.TextSelector(
selector.TextSelectorConfig(type="text")
),
}
)
return self.async_show_form(step_id="user", data_schema=schema)
return self.async_show_form(step_id="user", data_schema=_cover_schema())
def _entity_title(hass, entity_id: str) -> str:
@@ -132,3 +134,100 @@ def _entity_title(hass, entity_id: str) -> str:
if state is None:
return entity_id
return state.attributes.get("friendly_name", entity_id)
def _binary_sensor_schema() -> vol.Schema:
return vol.Schema(
{
vol.Required(CONF_ENTITY_ID): selector.EntitySelector(
selector.EntitySelectorConfig(domain=["binary_sensor"])
),
vol.Optional(CONF_STATE_ADDRESS): selector.TextSelector(
selector.TextSelectorConfig(type="text")
),
}
)
def _cover_schema() -> vol.Schema:
return vol.Schema(
{
vol.Required(CONF_ENTITY_ID): selector.EntitySelector(
selector.EntitySelectorConfig(domain=["cover"])
),
vol.Optional(CONF_MOVE_LONG_ADDRESS): selector.TextSelector(
selector.TextSelectorConfig(type="text")
),
vol.Optional(CONF_MOVE_SHORT_ADDRESS): selector.TextSelector(
selector.TextSelectorConfig(type="text")
),
vol.Optional(CONF_STOP_ADDRESS): selector.TextSelector(
selector.TextSelectorConfig(type="text")
),
vol.Optional(CONF_POSITION_ADDRESS): selector.TextSelector(
selector.TextSelectorConfig(type="text")
),
vol.Optional(CONF_POSITION_STATE_ADDRESS): selector.TextSelector(
selector.TextSelectorConfig(type="text")
),
vol.Optional(CONF_ANGLE_ADDRESS): selector.TextSelector(
selector.TextSelectorConfig(type="text")
),
vol.Optional(CONF_ANGLE_STATE_ADDRESS): selector.TextSelector(
selector.TextSelectorConfig(type="text")
),
}
)
def _validate_knx_addresses(
user_input: dict, keys: list[str]
) -> tuple[dict, dict[str, str]]:
errors: dict[str, str] = {}
cleaned = dict(user_input)
for key in keys:
if key not in cleaned:
continue
value = cleaned.get(key)
if value is None:
cleaned.pop(key, None)
continue
try:
normalized = _normalize_group_address(str(value))
except ValueError:
errors[key] = "invalid_ga"
continue
if normalized == "":
cleaned.pop(key, None)
else:
cleaned[key] = normalized
return cleaned, errors
def _normalize_group_address(value: str) -> str:
text = value.strip()
if not text:
return ""
parts = text.split("/")
if len(parts) == 2:
main, sub = _parse_int(parts[0]), _parse_int(parts[1])
if not (0 <= main <= 31 and 0 <= sub <= 2047):
raise ValueError("group address out of range")
return f"{main}/{sub}"
if len(parts) == 3:
main, middle, sub = (
_parse_int(parts[0]),
_parse_int(parts[1]),
_parse_int(parts[2]),
)
if not (0 <= main <= 31 and 0 <= middle <= 7 and 0 <= sub <= 255):
raise ValueError("group address out of range")
return f"{main}/{middle}/{sub}"
raise ValueError("invalid group address format")
def _parse_int(value: str) -> int:
text = value.strip()
if text == "":
raise ValueError("empty group address part")
return int(text)

View File

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

View File

@@ -3,6 +3,9 @@
"abort": {
"knx_not_configured": "Set up the Home Assistant KNX integration first."
},
"error": {
"invalid_ga": "Invalid KNX group address. Use X/Y/Z (0-31/0-7/0-255) or X/Y (0-31/0-2047)."
},
"step": {
"user": {
"title": "HA KNX Bridge",
@@ -13,7 +16,18 @@
}
}
},
"options": {
"step": {
"init": {
"title": "HA KNX Bridge Options",
"description": "No options available yet."
}
}
},
"config_subentries": {
"error": {
"invalid_ga": "Invalid KNX group address. Use X/Y/Z (0-31/0-7/0-255) or X/Y (0-31/0-2047)."
},
"binary_sensor": {
"step": {
"user": {