diff --git a/CHANGELOG.md b/CHANGELOG.md index 5795199..9a59b58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 3cf3574..94db37c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bcs.yaml b/bcs.yaml new file mode 100644 index 0000000..a5472c8 --- /dev/null +++ b/bcs.yaml @@ -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 diff --git a/custom_components/ha_knx_bridge/config_flow.py b/custom_components/ha_knx_bridge/config_flow.py index 8ec5a16..1ea762b 100644 --- a/custom_components/ha_knx_bridge/config_flow.py +++ b/custom_components/ha_knx_bridge/config_flow.py @@ -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) diff --git a/custom_components/ha_knx_bridge/manifest.json b/custom_components/ha_knx_bridge/manifest.json index 9eb57a4..31f499f 100644 --- a/custom_components/ha_knx_bridge/manifest.json +++ b/custom_components/ha_knx_bridge/manifest.json @@ -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", diff --git a/custom_components/ha_knx_bridge/strings.json b/custom_components/ha_knx_bridge/strings.json index 265a7b4..9c0c757 100644 --- a/custom_components/ha_knx_bridge/strings.json +++ b/custom_components/ha_knx_bridge/strings.json @@ -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": {