6 Commits

Author SHA1 Message Date
6de191b10b Handle missing subentry support 2026-02-13 19:18:01 +01:00
2175e5919e Fix subentry type compatibility 2026-02-13 19:08:13 +01:00
83518c88ab Centralize DPT auto-selection 2026-02-13 14:13:31 +01:00
d91d3edc5a Add per-address invert toggles 2026-02-13 13:44:06 +01:00
d6ec48e2e6 Add switch port support 2026-02-13 13:25:49 +01:00
74f5133bc1 Stop tracking .idea prompt and state files 2026-02-13 13:18:09 +01:00
10 changed files with 558 additions and 98 deletions

2
.gitignore vendored
View File

@@ -1,6 +1,4 @@
.idea/
!.idea/START_PROMPT.md
!.idea/PROJECT_STATE.md
.DS_Store
__pycache__/
*.pyc

55
.idea/PROJECT_STATE.md generated
View File

@@ -1,55 +0,0 @@
# Project State: HA KNX Bridge
## Project Description
HA KNX Bridge is a Home Assistant custom integration that mirrors Home Assistant entities
to KNX group addresses and accepts KNX actions to control Home Assistant entities.
It is installed via HACS and configured through the Home Assistant UI (config flow).
The integration should:
- Reuse an existing Home Assistant KNX integration entry when available.
- Allow users to add "Ports" that bind a single HA entity to KNX group addresses.
- Auto-select appropriate KNX DPTs and request only the group addresses needed.
- Ignore missing/empty group addresses without errors.
Target compatibility: Home Assistant 2025.12 with forward compatibility to 2026.2.
Project rules:
- Keep `README.md` updated for user-relevant changes after each new version.
- Maintain `CHANGELOG.md` with newest entries at the top.
- Releases are created only when explicitly requested.
- Version number must match everywhere it is referenced (manifest, changelog, README, HACS if used).
## Current State (as of 2026-02-13)
Completed:
- Repo initialized with `main` branch and pushed to GitHub.
- HACS metadata (`hacs.json`) and base integration scaffold created.
- Config flow created with subentries ("Ports") for:
- Binary Sensor
- Cover
- KNX group address validation and normalization in subentry flows.
- Options flow skeleton added (no options yet).
- `bcs.yaml` metadata file added for BCS store listing.
- Bridge manager implements:
- Binary sensor state -> KNX send (DPT 1)
- Cover KNX incoming commands -> HA services
- Cover HA state -> KNX percent updates (DPT 5.001)
- README includes initial DPT mapping and roadmap.
- Project version set to 0.0.2 and `CHANGELOG.md` maintained.
Files created:
- `custom_components/ha_knx_bridge/__init__.py`
- `custom_components/ha_knx_bridge/bridge.py`
- `custom_components/ha_knx_bridge/config_flow.py`
- `custom_components/ha_knx_bridge/const.py`
- `custom_components/ha_knx_bridge/manifest.json`
- `custom_components/ha_knx_bridge/strings.json`
- `hacs.json`
- `README.md`
- `CHANGELOG.md`
- `.gitignore`
Open items / next steps:
- Validate and format KNX group address inputs in config flow.
- Add optional option flow for future settings.
- Expand entity coverage (light, switch, sensor, climate).
- Add tests (config flow, KNX mapping).
- Improve DPT auto-selection logic per entity features.

20
.idea/START_PROMPT.md generated
View File

@@ -1,20 +0,0 @@
# Start Prompt (Use for Every New Chat)
You are working on the `HA-KNX-Bridge` project.
Required steps at the start of every new chat:
1. Read `.idea/PROJECT_STATE.md` to understand the goal and current status.
2. Scan the repository for recent code changes relevant to the request.
3. If you make changes, update `.idea/PROJECT_STATE.md` to reflect the new state and date.
Constraints:
- Keep the project description and state accurate and concise.
- Always write an explicit date (YYYY-MM-DD) when updating the Current State section.
- Do not invent progress; only record what is verified in the repo.
- After any user-relevant change and version bump, update `README.md`.
- Maintain `CHANGELOG.md` with newest entries at the top, format:
- Version number
- Date (YYYY-MM-DD)
- Bullet list of changes
- Create releases only when explicitly requested.
- Keep the version number consistent in all files where it appears.

View File

@@ -1,5 +1,20 @@
# Changelog
## 0.0.7 - 2026-02-13
- Avoid crashing config entries when subentries are unsupported.
## 0.0.6 - 2026-02-13
- Fix config flow subentry type compatibility with older Home Assistant versions.
## 0.0.5 - 2026-02-13
- Centralize DPT auto-selection for KNX event registration per address.
## 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).
## 0.0.2 - 2026-02-13
- Validate and normalize KNX group addresses in subentry config flows.
- Add options flow skeleton for future settings.

View File

@@ -5,6 +5,7 @@ add "Ports" that bind an HA entity to KNX addresses for state updates and comman
Current minimal scope:
- Binary Sensor port (state -> KNX)
- Switch port (KNX -> HA commands, HA state -> KNX)
- Cover port (KNX -> HA commands, HA state -> KNX)
## Requirements
@@ -36,10 +37,27 @@ 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.
- `command_address` (DPT 1): KNX group address for on/off commands.
- `state_address` (DPT 1): KNX group address that receives HA on/off state.
## Notes
- For DPT 1.008 (Up/Down), the bridge treats `0 = Up/Open` and `1 = Down/Close`.
- For DPT 5.001, values are interpreted as 0-100 percent where 0 = closed and 100 = open.
- DPTs are auto-selected per address:
- Binary sensor `state_address`: DPT 1
- Switch `command_address`: DPT 1
- Switch `state_address`: DPT 1
- Cover `move_long_address`: DPT 1.008
- Cover `move_short_address`: DPT 1.007
- Cover `stop_address`: DPT 1
- Cover `position_address`: DPT 5.001
- Cover `position_state_address`: DPT 5.001
- Cover `angle_address`: DPT 5.001
- Cover `angle_state_address`: DPT 5.001
## Roadmap
- Optional standalone KNX connection (without requiring the HA KNX integration).
@@ -47,6 +65,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.2
- Current version: 0.0.7
- `CHANGELOG.md` lists versions with the newest entries at the top.
- Release creation is manual and only done when explicitly requested.

View File

@@ -12,6 +12,10 @@ from homeassistant.helpers import event as event_helper
from .const import (
CONF_ANGLE_ADDRESS,
CONF_ANGLE_STATE_ADDRESS,
ADDRESS_VALUE_TYPE,
CONF_COMMAND_ADDRESS,
CONF_INVERT_INCOMING,
CONF_INVERT_OUTGOING,
CONF_MOVE_LONG_ADDRESS,
CONF_MOVE_SHORT_ADDRESS,
CONF_POSITION_ADDRESS,
@@ -29,18 +33,52 @@ 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)
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:
@@ -51,14 +89,15 @@ 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"):
raise ConfigEntryNotReady("KNX integration services not available")
binary_ports, cover_ports = self._load_ports()
self._register_outgoing(binary_ports, cover_ports)
await self._register_incoming(cover_ports)
binary_ports, switch_ports, cover_ports = self._load_ports()
self._register_outgoing(binary_ports, switch_ports, cover_ports)
await self._register_incoming(switch_ports, cover_ports)
async def async_unload(self) -> None:
for unsub in self._unsub_listeners:
@@ -71,8 +110,11 @@ class BridgeManager:
await self._unregister_knx_events()
def _load_ports(self) -> tuple[list[BinarySensorPort], list[CoverPort]]:
def _load_ports(
self,
) -> tuple[list[BinarySensorPort], list[SwitchPort], list[CoverPort]]:
binary_ports: list[BinarySensorPort] = []
switch_ports: list[SwitchPort] = []
cover_ports: list[CoverPort] = []
subentries = getattr(self.entry, "subentries", [])
@@ -83,6 +125,34 @@ 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":
switch_ports.append(
SwitchPort(
entity_id=data["entity_id"],
command_address=_clean_address(
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":
@@ -90,52 +160,173 @@ 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))
),
)
)
return binary_ports, cover_ports
return binary_ports, switch_ports, cover_ports
def _register_outgoing(
self, binary_ports: list[BinarySensorPort], cover_ports: list[CoverPort]
self,
binary_ports: list[BinarySensorPort],
switch_ports: list[SwitchPort],
cover_ports: list[CoverPort],
) -> None:
for port in binary_ports:
if not port.state_address:
continue
self._remember_address_options(
port.state_address,
_get_value_type(CONF_STATE_ADDRESS),
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)
)
)
for port in switch_ports:
if not port.state_address:
continue
self._remember_address_options(
port.state_address,
_get_value_type(CONF_STATE_ADDRESS),
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)
)
)
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,
_get_value_type(CONF_POSITION_STATE_ADDRESS),
port.position_state_invert_incoming,
port.position_state_invert_outgoing,
)
if port.angle_state_address:
self._remember_address_options(
port.angle_state_address,
_get_value_type(CONF_ANGLE_STATE_ADDRESS),
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)
)
)
async def _register_incoming(self, cover_ports: list[CoverPort]) -> None:
async def _register_incoming(
self, switch_ports: list[SwitchPort], cover_ports: list[CoverPort]
) -> None:
for port in switch_ports:
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,
CONF_MOVE_LONG_ADDRESS,
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,
CONF_MOVE_SHORT_ADDRESS,
port.move_short_invert_incoming,
port.move_short_invert_outgoing,
port,
"move_short",
)
self._register_knx_address(
port.stop_address,
CONF_STOP_ADDRESS,
port.stop_invert_incoming,
port.stop_invert_outgoing,
port,
"stop",
)
self._register_knx_address(
port.position_address,
CONF_POSITION_ADDRESS,
port.position_invert_incoming,
port.position_invert_outgoing,
port,
"position",
)
self._register_knx_address(
port.angle_address,
CONF_ANGLE_ADDRESS,
port.angle_invert_incoming,
port.angle_invert_outgoing,
port,
"angle",
)
if self._address_handlers:
@@ -148,7 +339,9 @@ class BridgeManager:
def _register_knx_address(
self,
address: str | None,
value_type: str | None,
address_key: str,
invert_incoming: bool,
invert_outgoing: bool,
port: CoverPort,
action: str,
) -> None:
@@ -158,8 +351,45 @@ class BridgeManager:
self._address_handlers[address] = lambda event: self._handle_cover_action(
port, action, event
)
value_type = _get_value_type(address_key)
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:
return
self._address_handlers[address] = lambda event: self._handle_switch_action(
port, event
)
self._remember_address_options(
address,
_get_value_type(CONF_COMMAND_ADDRESS),
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}
@@ -187,6 +417,24 @@ 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
def _switch_changed(self, port: SwitchPort) -> callable[[Event], Any]:
async def _handler(event: Event) -> None:
new_state: State | None = event.data.get("new_state")
if new_state is None:
return
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
@@ -200,11 +448,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
@@ -229,6 +491,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:
@@ -250,6 +515,18 @@ class BridgeManager:
port.entity_id, "set_cover_tilt_position", {"tilt_position": value}
)
async def _handle_switch_action(self, port: SwitchPort, event: Event) -> None:
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:
await self._call_switch_service(port.entity_id, "turn_on")
async def _call_cover_service(
self, entity_id: str, service: str, service_data: dict[str, Any] | None = None
) -> None:
@@ -260,6 +537,16 @@ class BridgeManager:
"cover", service, data, blocking=False
)
async def _call_switch_service(
self, entity_id: str, service: str, service_data: dict[str, Any] | None = None
) -> None:
data = {"entity_id": entity_id}
if service_data:
data.update(service_data)
await self.hass.services.async_call(
"switch", service, data, blocking=False
)
async def _knx_send_raw(self, address: str | None, payload: int) -> None:
if not address:
return
@@ -305,3 +592,43 @@ 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}"
def _get_value_type(address_key: str) -> str | None:
return ADDRESS_VALUE_TYPE.get(address_key)

View File

@@ -12,6 +12,9 @@ from homeassistant.helpers import selector
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,
@@ -25,6 +28,9 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
KNX_DOMAIN = "knx"
_SUBENTRY_TYPE = getattr(
config_entries, "SubentryType", getattr(config_entries, "ConfigEntrySubentryType", None)
)
class HAKnxBridgeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@@ -62,11 +68,19 @@ class HAKnxBridgeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_supported_subentry_types(config_entry):
if _SUBENTRY_TYPE is None:
_LOGGER.warning(
"Config subentries are not supported in this Home Assistant version"
)
return {}
return {
"binary_sensor": config_entries.SubentryType(
"binary_sensor": _SUBENTRY_TYPE(
name="Binary Sensor Port", flow_class=BinarySensorPortSubentryFlow
),
"cover": config_entries.SubentryType(
"switch": _SUBENTRY_TYPE(
name="Switch Port", flow_class=SwitchPortSubentryFlow
),
"cover": _SUBENTRY_TYPE(
name="Cover Port", flow_class=CoverPortSubentryFlow
),
}
@@ -90,7 +104,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(
@@ -102,6 +119,24 @@ class BinarySensorPortSubentryFlow(config_entries.ConfigSubentryFlow):
return self.async_show_form(step_id="user", data_schema=_binary_sensor_schema())
class SwitchPortSubentryFlow(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_COMMAND_ADDRESS, CONF_STATE_ADDRESS]
)
if errors:
return self.async_show_form(
step_id="user", data_schema=_switch_schema(), errors=errors
)
title = _entity_title(self.hass, user_input[CONF_ENTITY_ID])
return self.async_create_entry(title=title, data=user_input)
return self.async_show_form(step_id="user", data_schema=_switch_schema())
class CoverPortSubentryFlow(config_entries.ConfigSubentryFlow):
VERSION = 1
@@ -145,6 +180,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()
),
}
)
@@ -158,24 +199,94 @@ 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()
),
}
)
def _switch_schema() -> vol.Schema:
return vol.Schema(
{
vol.Required(CONF_ENTITY_ID): selector.EntitySelector(
selector.EntitySelectorConfig(domain=["switch"])
),
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()
),
}
)
@@ -191,6 +302,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))
@@ -199,6 +312,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
@@ -231,3 +346,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}"

View File

@@ -3,6 +3,9 @@ DOMAIN = "ha_knx_bridge"
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"
@@ -11,3 +14,22 @@ CONF_POSITION_ADDRESS = "position_address"
CONF_POSITION_STATE_ADDRESS = "position_state_address"
CONF_ANGLE_ADDRESS = "angle_address"
CONF_ANGLE_STATE_ADDRESS = "angle_state_address"
ADDRESS_DPT_MAP: dict[str, str] = {
CONF_STATE_ADDRESS: "1",
CONF_COMMAND_ADDRESS: "1",
CONF_MOVE_LONG_ADDRESS: "1.008",
CONF_MOVE_SHORT_ADDRESS: "1.007",
CONF_STOP_ADDRESS: "1",
CONF_POSITION_ADDRESS: "5.001",
CONF_POSITION_STATE_ADDRESS: "5.001",
CONF_ANGLE_ADDRESS: "5.001",
CONF_ANGLE_STATE_ADDRESS: "5.001",
}
ADDRESS_VALUE_TYPE: dict[str, str] = {
CONF_POSITION_ADDRESS: "percent",
CONF_POSITION_STATE_ADDRESS: "percent",
CONF_ANGLE_ADDRESS: "percent",
CONF_ANGLE_STATE_ADDRESS: "percent",
}

View File

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

View File

@@ -34,7 +34,25 @@
"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"
}
}
}
},
"switch": {
"step": {
"user": {
"title": "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"
}
}
}
@@ -46,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"
}
}
}