2 Commits

Author SHA1 Message Date
127fa98471 Track project prompt and state files 2026-02-13 12:14:45 +01:00
b733f9d62d Add KNX address validation and bcs metadata 2026-02-13 11:54:14 +01:00
9 changed files with 259 additions and 42 deletions

2
.gitignore vendored
View File

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

55
.idea/PROJECT_STATE.md generated Normal file
View File

@@ -0,0 +1,55 @@
# 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 Normal file
View File

@@ -0,0 +1,20 @@
# 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,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": {