diff --git a/CHANGELOG.md b/CHANGELOG.md index f2a2671..0433ddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 0.0.4 - 2026-02-13 +- Add per-group-address invert toggles for incoming and outgoing KNX payloads. + ## 0.0.3 - 2026-02-13 - Add switch port support with KNX command/state mapping (DPT 1). diff --git a/README.md b/README.md index 6106bb6..5c79681 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Current minimal scope: 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). +Each group address has `invert incoming` and `invert outgoing` toggles to flip KNX payloads. ### Switch Port - `entity_id`: the HA switch to control/monitor. @@ -53,6 +54,6 @@ Group address format must be `X/Y/Z` (0-31/0-7/0-255) or `X/Y` (0-31/0-2047). - Advanced DPT mapping options and inversion settings. ## Versioning and Releases -- Current version: 0.0.3 +- Current version: 0.0.4 - `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 5e4664b..548fab0 100644 --- a/custom_components/ha_knx_bridge/bridge.py +++ b/custom_components/ha_knx_bridge/bridge.py @@ -13,6 +13,8 @@ from .const import ( CONF_ANGLE_ADDRESS, CONF_ANGLE_STATE_ADDRESS, CONF_COMMAND_ADDRESS, + CONF_INVERT_INCOMING, + CONF_INVERT_OUTGOING, CONF_MOVE_LONG_ADDRESS, CONF_MOVE_SHORT_ADDRESS, CONF_POSITION_ADDRESS, @@ -30,18 +32,34 @@ KNX_DOMAIN = "knx" class BinarySensorPort: entity_id: str state_address: str | None + state_invert_incoming: bool + state_invert_outgoing: bool @dataclass(frozen=True) class CoverPort: entity_id: str move_long_address: str | None + move_long_invert_incoming: bool + move_long_invert_outgoing: bool move_short_address: str | None + move_short_invert_incoming: bool + move_short_invert_outgoing: bool stop_address: str | None + stop_invert_incoming: bool + stop_invert_outgoing: bool position_address: str | None + position_invert_incoming: bool + position_invert_outgoing: bool position_state_address: str | None + position_state_invert_incoming: bool + position_state_invert_outgoing: bool angle_address: str | None + angle_invert_incoming: bool + angle_invert_outgoing: bool angle_state_address: str | None + angle_state_invert_incoming: bool + angle_state_invert_outgoing: bool @dataclass(frozen=True) @@ -49,6 +67,17 @@ class SwitchPort: entity_id: str command_address: str | None state_address: str | None + command_invert_incoming: bool + command_invert_outgoing: bool + state_invert_incoming: bool + state_invert_outgoing: bool + + +@dataclass(frozen=True) +class AddressOptions: + value_type: str | None + invert_incoming: bool + invert_outgoing: bool class BridgeManager: @@ -59,6 +88,7 @@ class BridgeManager: self._knx_event_unsub: callable | None = None self._address_handlers: dict[str, callable[[Event], Any]] = {} self._registered_addresses: list[tuple[str, str | None]] = [] + self._address_options: dict[str, AddressOptions] = {} async def async_setup(self) -> None: if not self.hass.services.has_service(KNX_DOMAIN, "send"): @@ -94,6 +124,12 @@ class BridgeManager: BinarySensorPort( entity_id=data["entity_id"], state_address=_clean_address(data.get(CONF_STATE_ADDRESS)), + state_invert_incoming=_clean_bool( + data.get(_invert_in_key(CONF_STATE_ADDRESS)) + ), + state_invert_outgoing=_clean_bool( + data.get(_invert_out_key(CONF_STATE_ADDRESS)) + ), ) ) elif subentry.type == "switch": @@ -104,6 +140,18 @@ class BridgeManager: data.get(CONF_COMMAND_ADDRESS) ), state_address=_clean_address(data.get(CONF_STATE_ADDRESS)), + command_invert_incoming=_clean_bool( + data.get(_invert_in_key(CONF_COMMAND_ADDRESS)) + ), + command_invert_outgoing=_clean_bool( + data.get(_invert_out_key(CONF_COMMAND_ADDRESS)) + ), + state_invert_incoming=_clean_bool( + data.get(_invert_in_key(CONF_STATE_ADDRESS)) + ), + state_invert_outgoing=_clean_bool( + data.get(_invert_out_key(CONF_STATE_ADDRESS)) + ), ) ) elif subentry.type == "cover": @@ -111,16 +159,58 @@ class BridgeManager: CoverPort( entity_id=data["entity_id"], move_long_address=_clean_address(data.get(CONF_MOVE_LONG_ADDRESS)), + move_long_invert_incoming=_clean_bool( + data.get(_invert_in_key(CONF_MOVE_LONG_ADDRESS)) + ), + move_long_invert_outgoing=_clean_bool( + data.get(_invert_out_key(CONF_MOVE_LONG_ADDRESS)) + ), move_short_address=_clean_address(data.get(CONF_MOVE_SHORT_ADDRESS)), + move_short_invert_incoming=_clean_bool( + data.get(_invert_in_key(CONF_MOVE_SHORT_ADDRESS)) + ), + move_short_invert_outgoing=_clean_bool( + data.get(_invert_out_key(CONF_MOVE_SHORT_ADDRESS)) + ), stop_address=_clean_address(data.get(CONF_STOP_ADDRESS)), + stop_invert_incoming=_clean_bool( + data.get(_invert_in_key(CONF_STOP_ADDRESS)) + ), + stop_invert_outgoing=_clean_bool( + data.get(_invert_out_key(CONF_STOP_ADDRESS)) + ), position_address=_clean_address(data.get(CONF_POSITION_ADDRESS)), + position_invert_incoming=_clean_bool( + data.get(_invert_in_key(CONF_POSITION_ADDRESS)) + ), + position_invert_outgoing=_clean_bool( + data.get(_invert_out_key(CONF_POSITION_ADDRESS)) + ), position_state_address=_clean_address( data.get(CONF_POSITION_STATE_ADDRESS) ), + position_state_invert_incoming=_clean_bool( + data.get(_invert_in_key(CONF_POSITION_STATE_ADDRESS)) + ), + position_state_invert_outgoing=_clean_bool( + data.get(_invert_out_key(CONF_POSITION_STATE_ADDRESS)) + ), angle_address=_clean_address(data.get(CONF_ANGLE_ADDRESS)), + angle_invert_incoming=_clean_bool( + data.get(_invert_in_key(CONF_ANGLE_ADDRESS)) + ), + angle_invert_outgoing=_clean_bool( + data.get(_invert_out_key(CONF_ANGLE_ADDRESS)) + ), angle_state_address=_clean_address( data.get(CONF_ANGLE_STATE_ADDRESS) ), + angle_state_invert_incoming=_clean_bool( + data.get(_invert_in_key(CONF_ANGLE_STATE_ADDRESS)) + ), + angle_state_invert_outgoing=_clean_bool( + data.get(_invert_out_key(CONF_ANGLE_STATE_ADDRESS)) + ), ) ) @@ -135,6 +225,12 @@ class BridgeManager: for port in binary_ports: if not port.state_address: continue + self._remember_address_options( + port.state_address, + None, + port.state_invert_incoming, + port.state_invert_outgoing, + ) self._unsub_listeners.append( event_helper.async_track_state_change_event( self.hass, [port.entity_id], self._binary_sensor_changed(port) @@ -144,6 +240,12 @@ class BridgeManager: for port in switch_ports: if not port.state_address: continue + self._remember_address_options( + port.state_address, + None, + port.state_invert_incoming, + port.state_invert_outgoing, + ) self._unsub_listeners.append( event_helper.async_track_state_change_event( self.hass, [port.entity_id], self._switch_changed(port) @@ -153,6 +255,20 @@ class BridgeManager: for port in cover_ports: if not (port.position_state_address or port.angle_state_address): continue + if port.position_state_address: + self._remember_address_options( + port.position_state_address, + "percent", + port.position_state_invert_incoming, + port.position_state_invert_outgoing, + ) + if port.angle_state_address: + self._remember_address_options( + port.angle_state_address, + "percent", + port.angle_state_invert_incoming, + port.angle_state_invert_outgoing, + ) self._unsub_listeners.append( event_helper.async_track_state_change_event( self.hass, [port.entity_id], self._cover_changed(port) @@ -163,17 +279,53 @@ class BridgeManager: self, switch_ports: list[SwitchPort], cover_ports: list[CoverPort] ) -> None: for port in switch_ports: - self._register_knx_switch_address(port.command_address, port) + self._register_knx_switch_address( + port.command_address, + port.command_invert_incoming, + port.command_invert_outgoing, + port, + ) for port in cover_ports: - self._register_knx_address(port.move_long_address, None, port, "move_long") - self._register_knx_address(port.move_short_address, None, port, "move_short") - self._register_knx_address(port.stop_address, None, port, "stop") self._register_knx_address( - port.position_address, "percent", port, "position" + port.move_long_address, + None, + port.move_long_invert_incoming, + port.move_long_invert_outgoing, + port, + "move_long", ) self._register_knx_address( - port.angle_address, "percent", port, "angle" + port.move_short_address, + None, + port.move_short_invert_incoming, + port.move_short_invert_outgoing, + port, + "move_short", + ) + self._register_knx_address( + port.stop_address, + None, + port.stop_invert_incoming, + port.stop_invert_outgoing, + port, + "stop", + ) + self._register_knx_address( + port.position_address, + "percent", + port.position_invert_incoming, + port.position_invert_outgoing, + port, + "position", + ) + self._register_knx_address( + port.angle_address, + "percent", + port.angle_invert_incoming, + port.angle_invert_outgoing, + port, + "angle", ) if self._address_handlers: @@ -187,6 +339,8 @@ class BridgeManager: self, address: str | None, value_type: str | None, + invert_incoming: bool, + invert_outgoing: bool, port: CoverPort, action: str, ) -> None: @@ -196,11 +350,16 @@ class BridgeManager: self._address_handlers[address] = lambda event: self._handle_cover_action( port, action, event ) + self._remember_address_options( + address, value_type, invert_incoming, invert_outgoing + ) self._registered_addresses.append((address, value_type)) def _register_knx_switch_address( self, address: str | None, + invert_incoming: bool, + invert_outgoing: bool, port: SwitchPort, ) -> None: if not address: @@ -208,8 +367,24 @@ class BridgeManager: self._address_handlers[address] = lambda event: self._handle_switch_action( port, event ) + self._remember_address_options( + address, None, invert_incoming, invert_outgoing + ) self._registered_addresses.append((address, None)) + def _remember_address_options( + self, + address: str, + value_type: str | None, + invert_incoming: bool, + invert_outgoing: bool, + ) -> None: + self._address_options[address] = AddressOptions( + value_type=value_type, + invert_incoming=invert_incoming, + invert_outgoing=invert_outgoing, + ) + async def _register_knx_events(self) -> None: for address, value_type in self._registered_addresses: data: dict[str, Any] = {"address": address} @@ -237,6 +412,9 @@ class BridgeManager: if new_state.state not in ("on", "off"): return payload = 1 if new_state.state == "on" else 0 + payload = _invert_value( + payload, port.state_address, self._address_options, "outgoing" + ) await self._knx_send_raw(port.state_address, payload) return _handler @@ -249,6 +427,9 @@ class BridgeManager: if new_state.state not in ("on", "off"): return payload = 1 if new_state.state == "on" else 0 + payload = _invert_value( + payload, port.state_address, self._address_options, "outgoing" + ) await self._knx_send_raw(port.state_address, payload) return _handler @@ -262,11 +443,25 @@ class BridgeManager: if port.position_state_address is not None: position = new_state.attributes.get("current_position") if position is not None: - await self._knx_send_percent(port.position_state_address, position) + position = _invert_value( + position, + port.position_state_address, + self._address_options, + "outgoing", + ) + await self._knx_send_percent( + port.position_state_address, position + ) if port.angle_state_address is not None: angle = new_state.attributes.get("current_tilt_position") if angle is not None: + angle = _invert_value( + angle, + port.angle_state_address, + self._address_options, + "outgoing", + ) await self._knx_send_percent(port.angle_state_address, angle) return _handler @@ -291,6 +486,9 @@ class BridgeManager: value = _extract_event_value(event) if value is None: return + value = _invert_value( + value, event.data.get("destination"), self._address_options, "incoming" + ) if action == "move_long": if value == 0: @@ -316,6 +514,9 @@ class BridgeManager: value = _extract_event_value(event) if value is None: return + value = _invert_value( + value, event.data.get("destination"), self._address_options, "incoming" + ) if value == 0: await self._call_switch_service(port.entity_id, "turn_off") elif value == 1: @@ -386,3 +587,39 @@ def _clean_address(address: Any) -> str | None: stripped = address.strip() return stripped or None return None + + +def _clean_bool(value: Any) -> bool: + return bool(value) + + +def _invert_value( + value: int, + address: str | None, + address_options: dict[str, AddressOptions], + direction: str, +) -> int: + if address is None: + return value + options = address_options.get(address) + if options is None: + return value + if direction == "incoming": + if not options.invert_incoming: + return value + else: + if not options.invert_outgoing: + return value + if options.value_type == "percent": + return 100 - value + if value not in (0, 1): + return value + return 0 if value == 1 else 1 + + +def _invert_in_key(address_key: str) -> str: + return f"{address_key}_{CONF_INVERT_INCOMING}" + + +def _invert_out_key(address_key: str) -> str: + return f"{address_key}_{CONF_INVERT_OUTGOING}" diff --git a/custom_components/ha_knx_bridge/config_flow.py b/custom_components/ha_knx_bridge/config_flow.py index 3c50460..a1f7c6c 100644 --- a/custom_components/ha_knx_bridge/config_flow.py +++ b/custom_components/ha_knx_bridge/config_flow.py @@ -13,6 +13,8 @@ from .const import ( CONF_ANGLE_ADDRESS, CONF_ANGLE_STATE_ADDRESS, CONF_COMMAND_ADDRESS, + CONF_INVERT_INCOMING, + CONF_INVERT_OUTGOING, CONF_KNX_ENTRY_ID, CONF_MOVE_LONG_ADDRESS, CONF_MOVE_SHORT_ADDRESS, @@ -94,7 +96,10 @@ class BinarySensorPortSubentryFlow(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_STATE_ADDRESS] + user_input, + [ + CONF_STATE_ADDRESS, + ], ) if errors: return self.async_show_form( @@ -167,6 +172,12 @@ def _binary_sensor_schema() -> vol.Schema: vol.Optional(CONF_STATE_ADDRESS): selector.TextSelector( selector.TextSelectorConfig(type="text") ), + vol.Optional(_invert_in_key(CONF_STATE_ADDRESS), default=False): ( + selector.BooleanSelector() + ), + vol.Optional(_invert_out_key(CONF_STATE_ADDRESS), default=False): ( + selector.BooleanSelector() + ), } ) @@ -180,24 +191,66 @@ def _cover_schema() -> vol.Schema: vol.Optional(CONF_MOVE_LONG_ADDRESS): selector.TextSelector( selector.TextSelectorConfig(type="text") ), + vol.Optional(_invert_in_key(CONF_MOVE_LONG_ADDRESS), default=False): ( + selector.BooleanSelector() + ), + vol.Optional(_invert_out_key(CONF_MOVE_LONG_ADDRESS), default=False): ( + selector.BooleanSelector() + ), vol.Optional(CONF_MOVE_SHORT_ADDRESS): selector.TextSelector( selector.TextSelectorConfig(type="text") ), + vol.Optional(_invert_in_key(CONF_MOVE_SHORT_ADDRESS), default=False): ( + selector.BooleanSelector() + ), + vol.Optional(_invert_out_key(CONF_MOVE_SHORT_ADDRESS), default=False): ( + selector.BooleanSelector() + ), vol.Optional(CONF_STOP_ADDRESS): selector.TextSelector( selector.TextSelectorConfig(type="text") ), + vol.Optional(_invert_in_key(CONF_STOP_ADDRESS), default=False): ( + selector.BooleanSelector() + ), + vol.Optional(_invert_out_key(CONF_STOP_ADDRESS), default=False): ( + selector.BooleanSelector() + ), vol.Optional(CONF_POSITION_ADDRESS): selector.TextSelector( selector.TextSelectorConfig(type="text") ), + vol.Optional(_invert_in_key(CONF_POSITION_ADDRESS), default=False): ( + selector.BooleanSelector() + ), + vol.Optional(_invert_out_key(CONF_POSITION_ADDRESS), default=False): ( + selector.BooleanSelector() + ), vol.Optional(CONF_POSITION_STATE_ADDRESS): selector.TextSelector( selector.TextSelectorConfig(type="text") ), + vol.Optional(_invert_in_key(CONF_POSITION_STATE_ADDRESS), default=False): ( + selector.BooleanSelector() + ), + vol.Optional(_invert_out_key(CONF_POSITION_STATE_ADDRESS), default=False): ( + selector.BooleanSelector() + ), vol.Optional(CONF_ANGLE_ADDRESS): selector.TextSelector( selector.TextSelectorConfig(type="text") ), + vol.Optional(_invert_in_key(CONF_ANGLE_ADDRESS), default=False): ( + selector.BooleanSelector() + ), + vol.Optional(_invert_out_key(CONF_ANGLE_ADDRESS), default=False): ( + selector.BooleanSelector() + ), vol.Optional(CONF_ANGLE_STATE_ADDRESS): selector.TextSelector( selector.TextSelectorConfig(type="text") ), + vol.Optional(_invert_in_key(CONF_ANGLE_STATE_ADDRESS), default=False): ( + selector.BooleanSelector() + ), + vol.Optional(_invert_out_key(CONF_ANGLE_STATE_ADDRESS), default=False): ( + selector.BooleanSelector() + ), } ) @@ -211,9 +264,21 @@ def _switch_schema() -> vol.Schema: vol.Optional(CONF_COMMAND_ADDRESS): selector.TextSelector( selector.TextSelectorConfig(type="text") ), + vol.Optional(_invert_in_key(CONF_COMMAND_ADDRESS), default=False): ( + selector.BooleanSelector() + ), + vol.Optional(_invert_out_key(CONF_COMMAND_ADDRESS), default=False): ( + selector.BooleanSelector() + ), vol.Optional(CONF_STATE_ADDRESS): selector.TextSelector( selector.TextSelectorConfig(type="text") ), + vol.Optional(_invert_in_key(CONF_STATE_ADDRESS), default=False): ( + selector.BooleanSelector() + ), + vol.Optional(_invert_out_key(CONF_STATE_ADDRESS), default=False): ( + selector.BooleanSelector() + ), } ) @@ -229,6 +294,8 @@ def _validate_knx_addresses( value = cleaned.get(key) if value is None: cleaned.pop(key, None) + cleaned.pop(_invert_in_key(key), None) + cleaned.pop(_invert_out_key(key), None) continue try: normalized = _normalize_group_address(str(value)) @@ -237,6 +304,8 @@ def _validate_knx_addresses( continue if normalized == "": cleaned.pop(key, None) + cleaned.pop(_invert_in_key(key), None) + cleaned.pop(_invert_out_key(key), None) else: cleaned[key] = normalized return cleaned, errors @@ -269,3 +338,11 @@ def _parse_int(value: str) -> int: if text == "": raise ValueError("empty group address part") return int(text) + + +def _invert_in_key(address_key: str) -> str: + return f"{address_key}_{CONF_INVERT_INCOMING}" + + +def _invert_out_key(address_key: str) -> str: + return f"{address_key}_{CONF_INVERT_OUTGOING}" diff --git a/custom_components/ha_knx_bridge/const.py b/custom_components/ha_knx_bridge/const.py index a4480e6..9684ffe 100644 --- a/custom_components/ha_knx_bridge/const.py +++ b/custom_components/ha_knx_bridge/const.py @@ -4,6 +4,8 @@ CONF_KNX_ENTRY_ID = "knx_entry_id" CONF_STATE_ADDRESS = "state_address" CONF_COMMAND_ADDRESS = "command_address" +CONF_INVERT_INCOMING = "invert_incoming" +CONF_INVERT_OUTGOING = "invert_outgoing" 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 a270b90..2b3ec64 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.3", + "version": "0.0.4", "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 f7b7aa4..634ac2d 100644 --- a/custom_components/ha_knx_bridge/strings.json +++ b/custom_components/ha_knx_bridge/strings.json @@ -34,7 +34,9 @@ "title": "Binary Sensor Port", "data": { "entity_id": "Binary sensor entity", - "state_address": "State group address (DPT 1)" + "state_address": "State group address (DPT 1)", + "state_address_invert_incoming": "Invert incoming", + "state_address_invert_outgoing": "Invert outgoing" } } } @@ -46,7 +48,11 @@ "data": { "entity_id": "Switch entity", "command_address": "Command group address (DPT 1)", - "state_address": "State 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" } } } @@ -58,12 +64,26 @@ "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_state_address": "State 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" } } }