From 502a33dbace479d3eeef93fb24eb32f28d8e969e Mon Sep 17 00:00:00 2001 From: bahmcloud Date: Fri, 13 Feb 2026 19:33:55 +0100 Subject: [PATCH] Add options flow fallback for ports --- CHANGELOG.md | 3 + README.md | 4 +- custom_components/ha_knx_bridge/bridge.py | 25 +++- .../ha_knx_bridge/config_flow.py | 114 +++++++++++++++++- custom_components/ha_knx_bridge/const.py | 2 + custom_components/ha_knx_bridge/manifest.json | 2 +- custom_components/ha_knx_bridge/strings.json | 65 +++++++++- 7 files changed, 202 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a77bd47..26e220b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 0.0.9 - 2026-02-13 +- Add options flow fallback to add/remove ports when subentries are unavailable. + ## 0.0.8 - 2026-02-13 - Improve subentry type detection for HA versions exposing different class names. diff --git a/README.md b/README.md index 56e1537..34f487b 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Current minimal scope: ## Configure 1. During setup, select the existing Home Assistant KNX integration entry. 2. Add Ports from the integration's configuration page. + If the "Add Port" UI is missing, open the integration options and manage ports + there (fallback for HA versions without subentry support). ### Binary Sensor Port - `entity_id`: the HA binary_sensor to mirror. @@ -65,6 +67,6 @@ Each group address has `invert incoming` and `invert outgoing` toggles to flip K - Advanced DPT mapping options and inversion settings. ## Versioning and Releases -- Current version: 0.0.8 +- Current version: 0.0.9 - `CHANGELOG.md` lists versions with the newest entries at the top. - Release creation is manual and only done when explicitly requested. diff --git a/custom_components/ha_knx_bridge/bridge.py b/custom_components/ha_knx_bridge/bridge.py index 56a5010..9fb6e02 100644 --- a/custom_components/ha_knx_bridge/bridge.py +++ b/custom_components/ha_knx_bridge/bridge.py @@ -18,6 +18,7 @@ from .const import ( CONF_INVERT_OUTGOING, CONF_MOVE_LONG_ADDRESS, CONF_MOVE_SHORT_ADDRESS, + CONF_PORTS, CONF_POSITION_ADDRESS, CONF_POSITION_STATE_ADDRESS, CONF_STATE_ADDRESS, @@ -117,10 +118,8 @@ class BridgeManager: switch_ports: list[SwitchPort] = [] cover_ports: list[CoverPort] = [] - subentries = getattr(self.entry, "subentries", []) - for subentry in subentries: - data = subentry.data - if subentry.type == "binary_sensor": + for port_type, data in _iter_port_configs(self.entry): + if port_type == "binary_sensor": binary_ports.append( BinarySensorPort( entity_id=data["entity_id"], @@ -133,7 +132,7 @@ class BridgeManager: ), ) ) - elif subentry.type == "switch": + elif port_type == "switch": switch_ports.append( SwitchPort( entity_id=data["entity_id"], @@ -155,7 +154,7 @@ class BridgeManager: ), ) ) - elif subentry.type == "cover": + elif port_type == "cover": cover_ports.append( CoverPort( entity_id=data["entity_id"], @@ -632,3 +631,17 @@ def _invert_out_key(address_key: str) -> str: def _get_value_type(address_key: str) -> str | None: return ADDRESS_VALUE_TYPE.get(address_key) + + +def _iter_port_configs(entry: ConfigEntry) -> list[tuple[str, dict[str, Any]]]: + ports: list[tuple[str, dict[str, Any]]] = [] + subentries = getattr(entry, "subentries", []) + for subentry in subentries: + ports.append((subentry.type, subentry.data)) + option_ports = entry.options.get(CONF_PORTS, []) + for port in option_ports: + port_type = port.get("type") + data = port.get("data", {}) + if port_type and isinstance(data, dict): + ports.append((port_type, data)) + return ports diff --git a/custom_components/ha_knx_bridge/config_flow.py b/custom_components/ha_knx_bridge/config_flow.py index 5ddac64..11657d6 100644 --- a/custom_components/ha_knx_bridge/config_flow.py +++ b/custom_components/ha_knx_bridge/config_flow.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import uuid import voluptuous as vol @@ -18,6 +19,8 @@ from .const import ( CONF_KNX_ENTRY_ID, CONF_MOVE_LONG_ADDRESS, CONF_MOVE_SHORT_ADDRESS, + CONF_PORTS, + CONF_PORT_ID, CONF_POSITION_ADDRESS, CONF_POSITION_STATE_ADDRESS, CONF_STATE_ADDRESS, @@ -89,11 +92,114 @@ class HAKnxBridgeOptionsFlow(config_entries.OptionsFlow): 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) + return self.async_show_menu( + step_id="init", + menu_options=[ + "add_binary_sensor", + "add_switch", + "add_cover", + "remove_port", + ], + ) - schema = vol.Schema({}) - return self.async_show_form(step_id="init", data_schema=schema) + async def async_step_add_binary_sensor(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="add_binary_sensor", + data_schema=_binary_sensor_schema(), + errors=errors, + ) + return await self._async_store_port("binary_sensor", user_input) + + return self.async_show_form( + step_id="add_binary_sensor", data_schema=_binary_sensor_schema() + ) + + async def async_step_add_switch(self, user_input: dict | None = None): + if user_input is not None: + user_input, errors = _validate_knx_addresses( + user_input, [CONF_COMMAND_ADDRESS, CONF_STATE_ADDRESS] + ) + if errors: + return self.async_show_form( + step_id="add_switch", + data_schema=_switch_schema(), + errors=errors, + ) + return await self._async_store_port("switch", user_input) + + return self.async_show_form( + step_id="add_switch", data_schema=_switch_schema() + ) + + async def async_step_add_cover(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="add_cover", data_schema=_cover_schema(), errors=errors + ) + return await self._async_store_port("cover", user_input) + + return self.async_show_form(step_id="add_cover", data_schema=_cover_schema()) + + async def async_step_remove_port(self, user_input: dict | None = None): + ports = list(self._config_entry.options.get(CONF_PORTS, [])) + if not ports: + return self.async_abort(reason="no_ports_to_remove") + + if user_input is not None: + port_id = user_input[CONF_PORT_ID] + ports = [port for port in ports if port.get("id") != port_id] + return self.async_create_entry(title="", data={CONF_PORTS: ports}) + + options = [ + { + "value": port.get("id"), + "label": port.get("title", port.get("id")), + } + for port in ports + if port.get("id") + ] + schema = vol.Schema( + { + vol.Required(CONF_PORT_ID): selector.SelectSelector( + selector.SelectSelectorConfig(options=options, mode="dropdown") + ) + } + ) + return self.async_show_form(step_id="remove_port", data_schema=schema) + + async def _async_store_port(self, port_type: str, user_input: dict): + ports = list(self._config_entry.options.get(CONF_PORTS, [])) + title = _entity_title(self.hass, user_input[CONF_ENTITY_ID]) + ports.append( + { + "id": uuid.uuid4().hex, + "type": port_type, + "title": title, + "data": user_input, + } + ) + return self.async_create_entry(title="", data={CONF_PORTS: ports}) class BinarySensorPortSubentryFlow(config_entries.ConfigSubentryFlow): diff --git a/custom_components/ha_knx_bridge/const.py b/custom_components/ha_knx_bridge/const.py index c86faca..a5808d4 100644 --- a/custom_components/ha_knx_bridge/const.py +++ b/custom_components/ha_knx_bridge/const.py @@ -6,6 +6,8 @@ CONF_STATE_ADDRESS = "state_address" CONF_COMMAND_ADDRESS = "command_address" CONF_INVERT_INCOMING = "invert_incoming" CONF_INVERT_OUTGOING = "invert_outgoing" +CONF_PORTS = "ports" +CONF_PORT_ID = "port_id" CONF_MOVE_LONG_ADDRESS = "move_long_address" CONF_MOVE_SHORT_ADDRESS = "move_short_address" diff --git a/custom_components/ha_knx_bridge/manifest.json b/custom_components/ha_knx_bridge/manifest.json index a77defa..30a3cb6 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.8", + "version": "0.0.9", "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 634ac2d..dc35739 100644 --- a/custom_components/ha_knx_bridge/strings.json +++ b/custom_components/ha_knx_bridge/strings.json @@ -17,10 +17,73 @@ } }, "options": { + "abort": { + "no_ports_to_remove": "There are no ports to remove yet." + }, "step": { "init": { "title": "HA KNX Bridge Options", - "description": "No options available yet." + "description": "Manage ports when subentries are unavailable.", + "menu_options": { + "add_binary_sensor": "Add binary sensor port", + "add_switch": "Add switch port", + "add_cover": "Add cover port", + "remove_port": "Remove port" + } + }, + "add_binary_sensor": { + "title": "Add Binary Sensor Port", + "data": { + "entity_id": "Binary sensor entity", + "state_address": "State group address (DPT 1)", + "state_address_invert_incoming": "Invert incoming", + "state_address_invert_outgoing": "Invert outgoing" + } + }, + "add_switch": { + "title": "Add Switch Port", + "data": { + "entity_id": "Switch entity", + "command_address": "Command group address (DPT 1)", + "command_address_invert_incoming": "Invert incoming", + "command_address_invert_outgoing": "Invert outgoing", + "state_address": "State group address (DPT 1)", + "state_address_invert_incoming": "Invert incoming", + "state_address_invert_outgoing": "Invert outgoing" + } + }, + "add_cover": { + "title": "Add Cover Port", + "data": { + "entity_id": "Cover entity", + "move_long_address": "Move long (DPT 1.008 Up/Down)", + "move_long_address_invert_incoming": "Invert incoming", + "move_long_address_invert_outgoing": "Invert outgoing", + "move_short_address": "Move short (DPT 1.007 Step)", + "move_short_address_invert_incoming": "Invert incoming", + "move_short_address_invert_outgoing": "Invert outgoing", + "stop_address": "Stop (DPT 1)", + "stop_address_invert_incoming": "Invert incoming", + "stop_address_invert_outgoing": "Invert outgoing", + "position_address": "Set position (DPT 5.001)", + "position_address_invert_incoming": "Invert incoming", + "position_address_invert_outgoing": "Invert outgoing", + "position_state_address": "State position (DPT 5.001)", + "position_state_address_invert_incoming": "Invert incoming", + "position_state_address_invert_outgoing": "Invert outgoing", + "angle_address": "Set tilt (DPT 5.001)", + "angle_address_invert_incoming": "Invert incoming", + "angle_address_invert_outgoing": "Invert outgoing", + "angle_state_address": "State tilt (DPT 5.001)", + "angle_state_address_invert_incoming": "Invert incoming", + "angle_state_address_invert_outgoing": "Invert outgoing" + } + }, + "remove_port": { + "title": "Remove Port", + "data": { + "port_id": "Port to remove" + } } } },