Skip to content

Instantly share code, notes, and snippets.

@aschamberger
Last active July 12, 2024 19:28
Show Gist options
  • Save aschamberger/5dd38a78ab01b824bb475b27943d4d73 to your computer and use it in GitHub Desktop.
Save aschamberger/5dd38a78ab01b824bb475b27943d4d73 to your computer and use it in GitHub Desktop.
# 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
#!/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