Last active
July 12, 2024 19:28
-
-
Save aschamberger/5dd38a78ab01b824bb475b27943d4d73 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# As there is currently no proper way to semantically model entities in ETS | |
# I assume everybody has some kind of group address configuration scheme | |
# (there are "functions" but as far as I can see they are not really usable | |
# at the moment and also not "migratable" from an existing GA structure). | |
# | |
# I want to have the single source ETS with rule based imports and no manual | |
# edits of them within Hass. With rerunning the rules after an ETS export | |
# you have all additions (or deletions?) reflected without adding anything | |
# in two places. At least in my configuration I can always assume there is | |
# at least one leading group address in order to define an entity. | |
# Out of scope: exposures from hass to KNX and catering for all kind of | |
# special situations where you need some GA configured in Hass (e.g. in an | |
# automation/blueprint to integrate Zigbee Lights with KNX). | |
# | |
# My group address naming convention is in general: | |
# <2-digit floor> <1_word_room_name> <multiple_words_function> <1_word_action> | |
# For the other rules see the yaml sections below. | |
# With this section one can rename functions, e.g. for outlets | |
rename: | |
"Terrasse Steckdose Oben links": "Terrasse Lichterkette" | |
# With this section one can rename rooms that were named after the building | |
# floorplan to e.g. the names of children | |
room_rename: | |
Kind_3: "Arbeiten" | |
# With these rules one can build sections for different entity types to create | |
# the list of entities from the exported ETS GA structure: | |
# area_by_word: 2 >> e.g. "EG Wohnzimmer Licht ..." = "Wohnzimmer" | |
# area: "StaticAreaName" | |
# description_hashtag: HassLight >> filter only "#HassLight" in GA description field | |
# lookup >> this is the base group address range that defines the entities | |
# strip_from_name >> strip string from name | |
# append_to_name >> append string to name | |
# slice_name: [3:] >> e.g. strip first three characters from "EG Wohnzimmer Licht ..." | |
# static >> add child elements to all entities | |
# groupBySub >> add attributes based on group address middle group with same group address sub | |
# groupByContainsName >> search for name instead of group address to match the child attributes | |
# you can use another "strip_from_name" or "append_to_name" there | |
# groupByOffset >> add attributes based on offset plus group address sub or increment | |
# as there is no hierarchy for areas this PR would be nice to have at least some labeling: | |
# https://github.com/home-assistant/core/pull/69996 | |
# label_by_word: 1 | |
# GA description hashtag functionality: | |
# "#HassIgnore" does not derive entity | |
# "#HassInvert" sets "invert: true" | |
# "#HassNoSyncState" sets "sync_state: false" | |
# "#HassButtonZero" sets "payload: 0" | |
# Licht | |
light: | |
entity: light | |
area_by_word: 2 | |
label_by_word: 1 | |
description_hashtag: HassLight | |
lookup: 1/1/* | |
strip_from_name: " Schalten" | |
slice_name: "[3:]" | |
groupBySub: | |
address: 1/1/* | |
state_address: 1/4/* | |
light_dim: | |
entity: light | |
area_by_word: 2 | |
label_by_word: 1 | |
description_hashtag: HassLightDim | |
lookup: 1/1/* | |
strip_from_name: " Schalten" | |
slice_name: "[3:]" | |
groupBySub: | |
address: 1/1/* | |
state_address: 1/4/* | |
brightness_address: 1/3/* | |
brightness_state_address: 1/5/* | |
light_tw: | |
entity: light | |
area_by_word: 2 | |
label_by_word: 1 | |
description_hashtag: HassLightTW | |
lookup: 1/1/* | |
strip_from_name: " Schalten" | |
slice_name: "[3:]" | |
static: | |
color_temperature_mode: absolute | |
groupBySub: | |
address: 1/1/* | |
state_address: 1/4/* | |
brightness_address: 1/3/* | |
brightness_state_address: 1/5/* | |
#color_temperature_address: | |
color_temperature_state_address: 1/6/* | |
light_rgbw: | |
entity: light | |
area_by_word: 2 | |
label_by_word: 1 | |
description_hashtag: HassLightRGBW | |
lookup: 1/1/* | |
strip_from_name: " Schalten" | |
slice_name: "[3:]" | |
static: | |
color_temperature_mode: absolute | |
groupBySub: | |
address: 1/1/* | |
state_address: 1/4/* | |
brightness_address: 1/3/* | |
brightness_state_address: 1/5/* | |
groupByOffset: | |
# start at position offset and increment by increment | |
rgbw_address: | |
_ga: 1/7/* | |
_offset: 200 | |
_increment: 2 | |
# start at position offset and increment by increment | |
rgbw_state_address: | |
_ga: 1/7/* | |
_offset: 201 | |
_increment: 2 | |
# Schalter | |
switch_outlet: | |
entity: switch | |
area_by_word: 2 | |
label_by_word: 1 | |
description_hashtag: HassSwitchOutlet | |
lookup: 1/1/* | |
strip_from_name: " Schalten" | |
slice_name: "[3:]" | |
static: | |
device_class: outlet | |
groupBySub: | |
address: 1/1/* | |
state_address: 1/4/* | |
switch_generic: | |
entity: switch | |
area_by_word: 2 | |
label_by_word: 1 | |
description_hashtag: HassSwitchGeneric | |
lookup: 1/1/* | |
strip_from_name: " Schalten" | |
slice_name: "[3:]" | |
static: | |
device_class: switch | |
groupBySub: | |
address: 1/1/* | |
state_address: 1/4/* | |
# Button | |
button_1: | |
entity: button | |
area_by_word: 2 | |
label_by_word: 1 | |
description_hashtag: HassButton | |
lookup: 1/1/* | |
strip_from_name: " Schalten" | |
slice_name: "[3:]" | |
groupBySub: | |
address: 1/1/* | |
button_2: | |
entity: button | |
area_by_word: 2 | |
label_by_word: 1 | |
lookup: 1/0/[118-123] | |
slice_name: "[3:]" | |
groupBySub: | |
address: 1/0/* | |
# RTR | |
climate: | |
entity: climate | |
area_by_word: 2 | |
label_by_word: 1 | |
lookup: 2/2/[0-6,9-15] | |
strip_from_name: " Temp Soll" | |
append_to_name: " RTR" | |
slice_name: "[3:]" | |
static: | |
temperature_step: 0.5 | |
groupByContainsName: | |
temperature_address: 2/1/* | |
groupBySub: | |
target_temperature_state_address: 2/2/* | |
operation_mode_state_address: 2/3/* | |
command_value_state_address: 2/4/* | |
climate2: | |
entity: climate | |
area_by_word: 2 | |
label_by_word: 1 | |
lookup: 2/2/[7] | |
strip_from_name: " Temp Soll" | |
append_to_name: " RTR" | |
slice_name: "[3:]" | |
static: | |
temperature_step: 0.5 | |
groupByContainsName: | |
strip_from_name: " Boden" | |
temperature_address: 2/1/* | |
groupBySub: | |
target_temperature_state_address: 2/2/* | |
operation_mode_state_address: 2/3/* | |
command_value_state_address: 2/4/* | |
climate3: | |
entity: climate | |
area_by_word: 2 | |
label_by_word: 1 | |
lookup: 2/2/[8] | |
strip_from_name: " Temp Soll" | |
append_to_name: " RTR" | |
slice_name: "[3:]" | |
static: | |
temperature_step: 0.5 | |
groupByContainsName: | |
strip_from_name: " Wand" | |
temperature_address: 2/1/* | |
groupBySub: | |
target_temperature_state_address: 2/2/* | |
operation_mode_state_address: 2/3/* | |
command_value_state_address: 2/4/* | |
# Temperatur | |
temperature: | |
entity: sensor | |
area_by_word: 2 | |
label_by_word: 1 | |
lookup: 2/1/* | |
slice_name: "[3:]" | |
strip_from_name: " Ist" | |
static: | |
type: temperature | |
state_class: measurement | |
groupBySub: | |
state_address: 2/1/* | |
# Feuchte | |
humidity: | |
entity: sensor | |
area_by_word: 2 | |
label_by_word: 1 | |
lookup: 2/7/* | |
slice_name: "[3:]" | |
static: | |
type: humidity | |
state_class: measurement | |
groupBySub: | |
state_address: 2/7/* | |
# Jalousien | |
cover_blinds: | |
entity: cover | |
area_by_word: 2 | |
label_by_word: 1 | |
lookup: 3/1/[5-6] | |
strip_from_name: " Auf/Ab" | |
slice_name: "[3:]" | |
static: | |
device_class: shutter | |
groupBySub: | |
move_long_address: 3/1/* | |
stop_address: 3/2/* | |
position_address: 3/5/* | |
groupByOffset: | |
# start at position offset and increment by increment | |
angle_address: | |
_ga: 3/5/* | |
_offset: 20 | |
_increment: 1 | |
# start at position offset and add lookup GA sub | |
position_state_address: | |
_ga: 3/6/* | |
_offset: 40 | |
# start at position offset and increment by increment | |
angle_state_address: | |
_ga: 3/6/* | |
_offset: 60 | |
_increment: 1 | |
#Rolläden | |
cover_shutter: | |
entity: cover | |
area_by_word: 2 | |
label_by_word: 1 | |
lookup: 3/1/[0-4,7-18] | |
strip_from_name: " Auf/Ab" | |
slice_name: "[3:]" | |
static: | |
device_class: blind | |
groupBySub: | |
move_long_address: 3/1/* | |
stop_address: 3/2/* | |
position_address: 3/5/* | |
groupByOffset: | |
# start at position offset and add lookup GA sub | |
position_state_address: | |
_ga: 3/6/* | |
_offset: 40 | |
#Markise | |
cover_awning: | |
entity: cover | |
area_by_word: 2 | |
label_by_word: 1 | |
lookup: 3/1/[100] | |
strip_from_name: " Auf/Ab" | |
slice_name: "[3:]" | |
static: | |
device_class: awning | |
groupBySub: | |
move_long_address: 3/1/* | |
stop_address: 3/2/* | |
position_address: 3/5/* | |
groupByOffset: | |
# start at position offset and increment by increment | |
position_state_address: | |
_ga: 3/6/* | |
_offset: 120 | |
_increment: 1 | |
#Zips | |
shade: | |
entity: cover | |
area_by_word: 2 | |
label_by_word: 1 | |
lookup: 3/1/[101-102] | |
strip_from_name: " Auf/Ab" | |
slice_name: "[3:]" | |
static: | |
device_class: shade | |
groupBySub: | |
move_long_address: 3/1/* | |
stop_address: 3/2/* | |
position_address: 3/5/* | |
groupByOffset: | |
# start at position offset and increment by increment | |
position_state_address: | |
_ga: 3/6/* | |
_offset: 121 | |
_increment: 1 | |
# Fensterkontakte | |
window_contact: | |
entity: binary_sensor | |
area_by_word: 2 | |
label_by_word: 1 | |
lookup: 4/1/[0-26,31-34] | |
slice_name: "[3:]" | |
static: | |
device_class: window | |
groupBySub: | |
state_address: 4/1/* | |
# Riegelkontakt | |
door_lock_state: | |
entity: binary_sensor | |
area_by_word: 2 | |
label_by_word: 1 | |
lookup: 4/2/[0,4] | |
slice_name: "[3:]" | |
static: | |
device_class: lock | |
groupBySub: | |
state_address: 4/2/* | |
# Türkontakte | |
door_contact: | |
entity: binary_sensor | |
area_by_word: 2 | |
label_by_word: 1 | |
lookup: 4/2/[1-2] | |
slice_name: "[3:]" | |
static: | |
device_class: door | |
groupBySub: | |
state_address: 4/2/* | |
# Tor | |
gate_contact: | |
entity: binary_sensor | |
area_by_word: 2 | |
label_by_word: 1 | |
lookup: 4/2/[3] | |
slice_name: "[3:]" | |
static: | |
device_class: door | |
groupBySub: | |
state_address: 4/2/* | |
# Helligkeit Außen | |
illuminance: | |
entity: sensor | |
area_by_word: 2 | |
label_by_word: 1 | |
lookup: 7/4/[3-6] | |
slice_name: "[3:]" | |
static: | |
type: illuminance | |
state_class: measurement | |
groupBySub: | |
state_address: 7/4/* | |
# Rauchmelder Gateway | |
smoke_detection: | |
entity: binary_sensor | |
area: "Elektro" | |
label: "HA" | |
lookup: 7/6/[0] | |
static: | |
device_class: smoke | |
groupBySub: | |
state_address: 7/6/* | |
smoke_gateway: | |
entity: binary_sensor | |
area: "Elektro" | |
label: "HA" | |
lookup: 7/6/[1] | |
static: | |
device_class: problem | |
groupBySub: | |
state_address: 7/6/* | |
# Wassermelder | |
water_detection: | |
entity: binary_sensor | |
area_by_word: 2 | |
label_by_word: 1 | |
lookup: 7/5/[1,7] | |
slice_name: "[3:]" | |
static: | |
device_class: moisture | |
groupBySub: | |
state_address: 7/5/* | |
water_suspend: | |
entity: button | |
area_by_word: 2 | |
label_by_word: 1 | |
lookup: 7/5/[3,9] | |
slice_name: "[3:]" | |
groupBySub: | |
address: 7/5/* | |
water_reset: | |
entity: button | |
area_by_word: 2 | |
label_by_word: 1 | |
lookup: 7/5/[2,8] | |
slice_name: "[3:]" | |
groupBySub: | |
address: 7/5/* | |
# PM Sperren | |
# pm_block: | |
# entity: switch | |
# area_by_word: 2 | |
# label_by_word: 1 | |
# lookup: 8/0/[50-64,70-73] | |
# slice_name: "[3:]" | |
# groupBySub: | |
# address: 8/0/* | |
#Rolläden, Jalousie, Markise, Zips Sperren | |
cover_block_1: | |
entity: switch | |
area_by_word: 2 | |
label_by_word: 1 | |
lookup: 3/1/[0-18,100-102] | |
strip_from_name: " Auf/Ab" | |
append_to_name: " Sperren" | |
slice_name: "[3:]" | |
groupBySub: | |
address: 3/7/* | |
cover_block_2: | |
entity: switch | |
area_by_word: 2 | |
label_by_word: 1 | |
lookup: 3/0/[23-29] | |
slice_name: "[3:]" | |
groupBySub: | |
address: 3/0/* | |
# WäTr, WaMa | |
tumble_washing_running: | |
entity: binary_sensor | |
area: "Technik" | |
label: "KG" | |
lookup: 6/3/[0-1] | |
static: | |
device_class: running | |
sync_state: false | |
groupBySub: | |
state_address: 6/3/* | |
tumble_washing_finished: | |
entity: binary_sensor | |
area: "Technik" | |
label: "KG" | |
lookup: 6/3/[2-3] | |
static: | |
sync_state: false | |
groupBySub: | |
state_address: 6/3/* | |
# Windalarm | |
windalarm: | |
entity: switch | |
area: "Garten" | |
label: "GA" | |
lookup: 7/3/[1] | |
static: | |
device_class: switch | |
groupBySub: | |
address: 7/3/* | |
# could be another use case | |
# start at position offset and increment by increment | |
# groupByRange: | |
# _middle_group: 3/6/* | |
# _start: 0 | |
# _length: 10 | |
# position_state_address: +0 | |
# angle_state_address: +1 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/python3 | |
import fnmatch | |
import io | |
import slugify as unicode_slug | |
from xknxproject.models import KNXProject | |
from xknxproject import XKNXProj | |
import yaml | |
# https://stackoverflow.com/a/39681672 | |
class MyDumper(yaml.Dumper): | |
def increase_indent(self, flow=False, indentless=False): | |
return super(MyDumper, self).increase_indent(flow, False) | |
knxproj: XKNXProj = XKNXProj( | |
path="myproject.knxproj", | |
# password="password", # optional | |
language="de-DE", # optional | |
) | |
project: KNXProject = knxproj.parse() | |
group_addresses = project["group_addresses"] | |
#print(group_addresses) | |
# https://pyyaml.org/wiki/PyYAMLDocumentation | |
stream = open('knx2hass.yml', 'r') | |
rules = yaml.load(stream, Loader=yaml.Loader) | |
#print(rules) | |
#print(rules['rename']) | |
names = [] | |
areas = [] | |
out = {} | |
for key, rule in rules.items(): | |
if key == 'room_rename': | |
continue | |
if key == 'rename': | |
continue | |
if 'entity' not in rule: | |
continue | |
print() | |
print(key + ':') | |
# find ga in list of GAs by lookup | |
ranges = [] | |
lookup = rule['lookup'].split('/') | |
# look for ranges, e.g. [1-4,7-19] | |
if '[' in lookup[2]: | |
ranges_list = lookup[2][1:-1].split(',') | |
for ranges_list_item in ranges_list: | |
ranges_list_item2 = ranges_list_item.split('-') | |
if len(ranges_list_item2) > 1: | |
ranges += range(int(ranges_list_item2[0]), int(ranges_list_item2[1])+1) | |
else: | |
ranges += [int(ranges_list_item2[0])] | |
# set wildcard | |
lookup[2] = '*' | |
lookup = '/'.join(lookup) | |
# https://stackoverflow.com/questions/11427138/python-wildcard-search-in-string | |
filtered = fnmatch.filter(group_addresses.keys(), lookup) | |
i = 0 | |
for item in filtered: | |
ga_number = item | |
ga = ga_number.split('/')[2] | |
ga_name = group_addresses[item]['name'] | |
# check if within ranges | |
if len(ranges) > 0 and int(ga) not in ranges: | |
continue | |
description_words = group_addresses[item]['description'].split() | |
# do not derive entity for this GA | |
if "#HassIgnore" in description_words: | |
continue | |
# check if description_hashtag | |
if 'description_hashtag' in rule: | |
hashtag = f"#{rule['description_hashtag']}" | |
# split and compare elements to not match #HassLight in #HassLightDim with string match | |
if hashtag not in description_words: | |
continue | |
# entity name | |
if 'strip_from_name' in rule: | |
name = ga_name.replace(rule['strip_from_name'], '') | |
else: | |
name = ga_name | |
if 'append_to_name' in rule: | |
name += rule['append_to_name'] | |
if 'slice_name' in rule: | |
start, stop = rule['slice_name'][1:-1].split(':') | |
if not start: | |
start = None | |
else: | |
start = int(start) | |
if not stop: | |
stop = None | |
else: | |
stop = int(stop) | |
name = name[slice(start, stop)] | |
entity = {'entity': rule['entity'], 'name': name } | |
# add area | |
if 'area' in rule: | |
entity['area'] = rule['area'] | |
entity['name'] = rule['area'] + " " + entity['name'] | |
elif 'area_by_word' in rule: | |
words = ga_name.split() | |
if len(words) >= rule['area_by_word']: | |
entity['area'] = words[rule['area_by_word']-1] | |
# display_name | |
display_name = entity['name'] | |
# display_name:rename? | |
if display_name in rules['rename']: | |
display_name = rules['rename'][display_name] | |
# display_name:rename room? | |
if entity['area'] in rules['room_rename']: | |
display_name = display_name.replace(entity['area'], rules['room_rename'][entity['area']]) | |
entity['area'] = rules['room_rename'][entity['area']] | |
# display_name: move room to end | |
words = display_name.split() | |
if len(words) >= 1: | |
words.append(words[0]) | |
del words[0] | |
display_name = ' '.join(words) | |
entity['display_name'] = display_name | |
# slugify to HA name | |
entity['area'] = unicode_slug.slugify(entity['area'], separator="_") | |
# add label, PR draft only: https://github.com/home-assistant/core/pull/69996 | |
if 'label' in rule: | |
entity['label'] = rule['label'] | |
if 'label_by_word' in rule: | |
words = ga_name.split() | |
if len(words) >= rule['label_by_word']: | |
entity['label'] = words[rule['label_by_word']-1] | |
# TODO: idea to add the description hashtags also as label | |
# groupBySub >> add attributes based on group address middle group with same group address sub | |
if 'groupBySub' in rule: | |
for attribute, ga_template in rule['groupBySub'].items(): | |
entity[attribute] = ga_template.replace('*', ga) | |
# groupByContainsName >> search for name instead of group address to match the child attributes | |
if 'groupByContainsName' in rule: | |
contains_name = name | |
name_items = dict(rule['groupByContainsName']) | |
if 'strip_from_name' in rule['groupByContainsName']: | |
contains_name = name.replace(rule['groupByContainsName']['strip_from_name'], '') | |
del name_items['strip_from_name'] | |
if 'append_to_name' in rule['groupByContainsName']: | |
contains_name += rule['groupByContainsName']['append_to_name'] | |
del name_items['append_to_name'] | |
name_found = False | |
for attribute, ga_template in name_items.items(): | |
# find ga in list of GAs by template | |
name_filtered = fnmatch.filter(group_addresses.keys(), ga_template) | |
# first try to match with appended text | |
for name_item in name_filtered: | |
if contains_name in group_addresses[name_item]['name']: | |
entity[attribute] = name_item | |
name_found = True | |
break | |
# otherwise try without | |
if not name_found: | |
for name_item in name_filtered: | |
if contains_name.replace(rule['append_to_name'], '') in group_addresses[name_item]['name']: | |
entity[attribute] = name_item | |
break | |
# groupByOffset >> add attributes based on offset plus group address sub or increment | |
if 'groupByOffset' in rule: | |
for attribute, ga_template in rule['groupByOffset'].items(): | |
attr_ga = ga_template['_offset'] | |
if '_increment' in ga_template: | |
attr_ga += i * ga_template['_increment'] | |
else: | |
attr_ga += int(ga) | |
entity[attribute] = ga_template['_ga'].replace('*', str(attr_ga)) | |
# add static attributes | |
if 'static' in rule: | |
entity |= rule['static'] | |
# set invert via hashtag in description | |
if (rule['entity'] == 'switch' or rule['entity'] == 'binary_sensor'): | |
if "#HassInvert" in description_words: | |
entity['invert'] = True | |
# disable sync_state via hashtag in description | |
if "#HassNoSyncState" in description_words: | |
entity['sync_state'] = False | |
# set payload 0 via hashtag in description | |
if "#HassButtonZero" in description_words: | |
entity['payload'] = 0 | |
# increment counter | |
i += 1 | |
print(entity) | |
if entity['area'] not in areas: | |
areas.append(entity['area']) | |
names.append(entity['name']) | |
entity_type = entity['entity'] | |
del entity['entity'] | |
del entity['display_name'] | |
del entity['area'] | |
del entity['label'] | |
if not entity_type in out: | |
out[entity_type] = [] | |
out[entity_type].append(entity) | |
outfile = io.open('knx_config.yaml', 'w', encoding='utf8') | |
#yaml.dump(out, outfile, Dumper=Dumper, default_flow_style=False, sort_keys=False, allow_unicode=True, indent=2) | |
yaml = yaml.dump(out, Dumper=MyDumper, default_flow_style=False, sort_keys=False, allow_unicode=True, indent=2) | |
print(' expose: !include knx_expose.yaml', file=outfile) | |
print(' '.join((' '+yaml).splitlines(True)), file=outfile) | |
print() | |
print("list of areas:") | |
for area in sorted(areas): | |
print(area) | |
print(len(areas)) | |
print() | |
print("list of names to screen for naming deviations:") | |
for name in sorted(names): | |
print(name) | |
print(len(names)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment