From 0a812e75a05223a87ffc5984a5ab107d8f020d81 Mon Sep 17 00:00:00 2001 From: James Muscat Date: Sat, 20 Jul 2024 22:47:27 +0100 Subject: [PATCH 01/13] Start publishing HA auto-discovery information. --- HADiscovery.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++ hc2mqtt.py | 15 +++++++++--- 2 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 HADiscovery.py diff --git a/HADiscovery.py b/HADiscovery.py new file mode 100644 index 0000000..925cbcb --- /dev/null +++ b/HADiscovery.py @@ -0,0 +1,66 @@ +import json +import re + +from HCSocket import now + + +def decamelcase(str): + split = re.findall(r'[A-Z](?:[a-z]+|[A-Z]*(?=[A-Z]|$))', str) + return f"{split[0]} {' '.join(split[1:]).lower()}".strip() + + +HA_DISCOVERY_PREFIX = "homeassistant" + + +def publish_ha_states(state, client, mqtt_topic): + pass + + +def publish_ha_discovery(device, client, mqtt_topic): + print(f"{now()} Publishing HA discovery for {device}") + + device_ident = device["host"] + device_name = device["name"] + device_description = device.get("description", {}) + + version_parts = filter( + lambda d : d is not None, + [ + device_description.get("version"), + device_description.get("revision") + ] + ) + + device_info = { + "identifiers": [device_ident], + "name": device_name, + "manufacturer": device_description.get("brand"), + "model": device_description.get("model"), + "sw_version": ".".join(version_parts) + } + + for feature in device["features"].values(): + access = feature.get("access", "none") + available = feature.get("available", False) + if available and (access == "read" or access == "readWrite"): + name_parts = feature["name"].split(".") + name = name_parts[-1] + feature_type = name_parts[-2] + + component_type = "sensor" # TODO use appropriate types + + discovery_topic = f"{HA_DISCOVERY_PREFIX}/{component_type}/hcpy/{device_ident}_{name}/config" + state_topic = f"{mqtt_topic}/{feature_type}/{name}" + # print(discovery_topic, state_topic) + + discovery_payload = json.dumps({ + "name": decamelcase(name), + "device": device_info, + "state_topic": state_topic, + "object_id": f"{device_ident}_{name}", + "unique_id": f"{device_ident}_{name}", + }) + print(discovery_topic) + # print(discovery_payload) + + client.publish(discovery_topic, discovery_payload, retain=False) # TODO make retain=True when stable diff --git a/hc2mqtt.py b/hc2mqtt.py index 53ee0b5..76b46ce 100755 --- a/hc2mqtt.py +++ b/hc2mqtt.py @@ -13,6 +13,7 @@ import paho.mqtt.client as mqtt from HCDevice import HCDevice from HCSocket import HCSocket, now +from HADiscovery import publish_ha_states, publish_ha_discovery @click.command() @@ -29,6 +30,7 @@ from HCSocket import HCSocket, now @click.option("--mqtt_clientname", default="hcpy1") @click.option("--domain_suffix", default="") @click.option("--debug/--no-debug", default=False) +@click.option("--ha-discovery", is_flag=True) @click_config_file.configuration_option() def hc2mqtt( devices_file: str, @@ -44,6 +46,7 @@ def hc2mqtt( mqtt_clientname: str, domain_suffix: str, debug: bool, + ha_discovery: bool ): def on_connect(client, userdata, flags, rc): @@ -74,6 +77,8 @@ def hc2mqtt( now(), device["name"], f"program topic: {mqtt_selected_program_topic}" ) client.subscribe(mqtt_selected_program_topic) + if ha_discovery: + publish_ha_discovery(device, client, mqtt_topic) else: print(now(), f"ERROR MQTT connection failed: {rc}") @@ -117,7 +122,7 @@ def hc2mqtt( f"Hello {devices_file=} {mqtt_host=} {mqtt_prefix=} " f"{mqtt_port=} {mqtt_username=} {mqtt_password=} " f"{mqtt_ssl=} {mqtt_cafile=} {mqtt_certfile=} {mqtt_keyfile=} {mqtt_clientname=}" - f"{domain_suffix=} {debug=}" + f"{domain_suffix=} {debug=} {ha_discovery=}" ) with open(devices_file, "r") as f: @@ -146,9 +151,9 @@ def hc2mqtt( client.connect(host=mqtt_host, port=mqtt_port, keepalive=70) for device in devices: - mqtt_topic = mqtt_prefix + device["name"] + mqtt_topic = mqtt_prefix + device["host"] thread = Thread( - target=client_connect, args=(client, device, mqtt_topic, domain_suffix, debug) + target=client_connect, args=(client, device, mqtt_topic, domain_suffix, debug, ha_discovery) ) thread.start() @@ -159,7 +164,7 @@ global dev dev = {} -def client_connect(client, device, mqtt_topic, domain_suffix, debug): +def client_connect(client, device, mqtt_topic, domain_suffix, debug, ha_discovery): host = device["host"] name = device["name"] @@ -193,6 +198,8 @@ def client_connect(client, device, mqtt_topic, domain_suffix, debug): msg = json.dumps(state) print(now(), name, f"publish to {mqtt_topic} with {msg}") client.publish(f"{mqtt_topic}/state", msg, retain=True) + if ha_discovery: + publish_ha_states(state, client, mqtt_topic) else: print( now(), From c111df49cb3c5d0a10032945dc5a387550a50ecb Mon Sep 17 00:00:00 2001 From: James Muscat Date: Sat, 20 Jul 2024 23:21:42 +0100 Subject: [PATCH 02/13] Publish state changes. At the moment this publishes all entity states every time one thing changes, which is not optimal. --- HADiscovery.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/HADiscovery.py b/HADiscovery.py index 925cbcb..9ca0de4 100644 --- a/HADiscovery.py +++ b/HADiscovery.py @@ -13,7 +13,10 @@ HA_DISCOVERY_PREFIX = "homeassistant" def publish_ha_states(state, client, mqtt_topic): - pass + for key, value in state.items(): + state_topic = f"{mqtt_topic}/{key}" + print(f"{now()} Publishing state for {key} at {state_topic}") + client.publish(state_topic, json.dumps(value)) def publish_ha_discovery(device, client, mqtt_topic): @@ -45,12 +48,12 @@ def publish_ha_discovery(device, client, mqtt_topic): if available and (access == "read" or access == "readWrite"): name_parts = feature["name"].split(".") name = name_parts[-1] - feature_type = name_parts[-2] + # feature_type = name_parts[-2] component_type = "sensor" # TODO use appropriate types discovery_topic = f"{HA_DISCOVERY_PREFIX}/{component_type}/hcpy/{device_ident}_{name}/config" - state_topic = f"{mqtt_topic}/{feature_type}/{name}" + state_topic = f"{mqtt_topic}/{name}" # print(discovery_topic, state_topic) discovery_payload = json.dumps({ @@ -63,4 +66,4 @@ def publish_ha_discovery(device, client, mqtt_topic): print(discovery_topic) # print(discovery_payload) - client.publish(discovery_topic, discovery_payload, retain=False) # TODO make retain=True when stable + client.publish(discovery_topic, discovery_payload, retain=True) From 11d91c964bb662f6182f19fbd42c3ce3737efa8c Mon Sep 17 00:00:00 2001 From: James Muscat Date: Sat, 20 Jul 2024 23:28:12 +0100 Subject: [PATCH 03/13] Include HA discovery code in Docker build. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index ce70c44..01e3f37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ RUN apt-get update && \ apt-get remove -y gcc python3-dev libssl-dev && \ apt-get autoremove -y -COPY hc2mqtt.py hc-login.py HCDevice.py HCSocket.py HCxml2json.py run.sh ./ +COPY hc2mqtt.py hc-login.py HADiscovery.py HCDevice.py HCSocket.py HCxml2json.py run.sh ./ RUN chmod a+x ./run.sh From 2186c61b5a4f54aa32c92e2d73d70e1d22347b7e Mon Sep 17 00:00:00 2001 From: James Muscat Date: Sun, 28 Jul 2024 21:59:23 +0100 Subject: [PATCH 04/13] Include events and options in discovery messages. --- HADiscovery.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/HADiscovery.py b/HADiscovery.py index 9ca0de4..60748af 100644 --- a/HADiscovery.py +++ b/HADiscovery.py @@ -43,12 +43,15 @@ def publish_ha_discovery(device, client, mqtt_topic): } for feature in device["features"].values(): + name_parts = feature["name"].split(".") + name = name_parts[-1] + feature_type = name_parts[-2] access = feature.get("access", "none") available = feature.get("available", False) - if available and (access == "read" or access == "readWrite"): - name_parts = feature["name"].split(".") - name = name_parts[-1] - # feature_type = name_parts[-2] + + if (feature_type == "Setting" and available and (access == "read" or access == "readWrite")) or \ + feature_type == "Event" or \ + feature_type == "Option": component_type = "sensor" # TODO use appropriate types From bf45fcd69ad890fd7d212258cbe0b6ba66242482 Mon Sep 17 00:00:00 2001 From: James Muscat Date: Thu, 8 Aug 2024 22:01:24 +0100 Subject: [PATCH 05/13] Use single state payload and HA value templates. --- HADiscovery.py | 12 +++--------- hc2mqtt.py | 8 +++----- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/HADiscovery.py b/HADiscovery.py index 60748af..c25864c 100644 --- a/HADiscovery.py +++ b/HADiscovery.py @@ -12,13 +12,6 @@ def decamelcase(str): HA_DISCOVERY_PREFIX = "homeassistant" -def publish_ha_states(state, client, mqtt_topic): - for key, value in state.items(): - state_topic = f"{mqtt_topic}/{key}" - print(f"{now()} Publishing state for {key} at {state_topic}") - client.publish(state_topic, json.dumps(value)) - - def publish_ha_discovery(device, client, mqtt_topic): print(f"{now()} Publishing HA discovery for {device}") @@ -56,13 +49,14 @@ def publish_ha_discovery(device, client, mqtt_topic): component_type = "sensor" # TODO use appropriate types discovery_topic = f"{HA_DISCOVERY_PREFIX}/{component_type}/hcpy/{device_ident}_{name}/config" - state_topic = f"{mqtt_topic}/{name}" # print(discovery_topic, state_topic) discovery_payload = json.dumps({ "name": decamelcase(name), "device": device_info, - "state_topic": state_topic, + "state_topic": f"{mqtt_topic}/state", + "availability_topic": f"{mqtt_topic}/LWT", + "value_template": "{{value_json." + name + "}}", "object_id": f"{device_ident}_{name}", "unique_id": f"{device_ident}_{name}", }) diff --git a/hc2mqtt.py b/hc2mqtt.py index 76b46ce..32b96ab 100755 --- a/hc2mqtt.py +++ b/hc2mqtt.py @@ -13,7 +13,7 @@ import paho.mqtt.client as mqtt from HCDevice import HCDevice from HCSocket import HCSocket, now -from HADiscovery import publish_ha_states, publish_ha_discovery +from HADiscovery import publish_ha_discovery @click.command() @@ -153,7 +153,7 @@ def hc2mqtt( for device in devices: mqtt_topic = mqtt_prefix + device["host"] thread = Thread( - target=client_connect, args=(client, device, mqtt_topic, domain_suffix, debug, ha_discovery) + target=client_connect, args=(client, device, mqtt_topic, domain_suffix, debug) ) thread.start() @@ -164,7 +164,7 @@ global dev dev = {} -def client_connect(client, device, mqtt_topic, domain_suffix, debug, ha_discovery): +def client_connect(client, device, mqtt_topic, domain_suffix, debug): host = device["host"] name = device["name"] @@ -198,8 +198,6 @@ def client_connect(client, device, mqtt_topic, domain_suffix, debug, ha_discover msg = json.dumps(state) print(now(), name, f"publish to {mqtt_topic} with {msg}") client.publish(f"{mqtt_topic}/state", msg, retain=True) - if ha_discovery: - publish_ha_states(state, client, mqtt_topic) else: print( now(), From e37369f90b259963a059b5701cb00b709364c176 Mon Sep 17 00:00:00 2001 From: James Muscat Date: Thu, 8 Aug 2024 22:14:46 +0100 Subject: [PATCH 06/13] All `Event`s act as binary sensors. --- HADiscovery.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/HADiscovery.py b/HADiscovery.py index c25864c..bb363e6 100644 --- a/HADiscovery.py +++ b/HADiscovery.py @@ -46,21 +46,26 @@ def publish_ha_discovery(device, client, mqtt_topic): feature_type == "Event" or \ feature_type == "Option": - component_type = "sensor" # TODO use appropriate types + component_type = "binary_sensor" if feature_type == "Event" else "sensor" # TODO use more appropriate types discovery_topic = f"{HA_DISCOVERY_PREFIX}/{component_type}/hcpy/{device_ident}_{name}/config" # print(discovery_topic, state_topic) - discovery_payload = json.dumps({ + discovery_payload = { "name": decamelcase(name), "device": device_info, "state_topic": f"{mqtt_topic}/state", - "availability_topic": f"{mqtt_topic}/LWT", + # "availability_topic": f"{mqtt_topic}/LWT", "value_template": "{{value_json." + name + "}}", "object_id": f"{device_ident}_{name}", "unique_id": f"{device_ident}_{name}", - }) + } + + if component_type == "binary_sensor": + discovery_payload["payload_on"] = "On" + discovery_payload["payload_off"] = "Off" + print(discovery_topic) # print(discovery_payload) - client.publish(discovery_topic, discovery_payload, retain=True) + client.publish(discovery_topic, json.dumps(discovery_payload), retain=True) From d9196ad5bfb5f3267235a5fed2048c60ee8c1644 Mon Sep 17 00:00:00 2001 From: James Muscat Date: Thu, 8 Aug 2024 23:05:54 +0100 Subject: [PATCH 07/13] Override some properties of state for special HA handling. --- HADiscovery.py | 86 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 5 deletions(-) diff --git a/HADiscovery.py b/HADiscovery.py index bb363e6..01b3c9d 100644 --- a/HADiscovery.py +++ b/HADiscovery.py @@ -12,6 +12,74 @@ def decamelcase(str): HA_DISCOVERY_PREFIX = "homeassistant" +MAGIC_OVERRIDES = { + "BackendConnected": { + "component_type": "binary_sensor", + "payload_values": { + "device_class": "connectivity", + "payload_on": True, + "payload_off": False + } + }, + "SoftwareUpdateAvailable": { + "component_type": "binary_sensor", + "payload_values": { + "device_class": "update", + "payload_on": "Present" + } + }, + "DoorState": { + "component_type": "binary_sensor", + "payload_values": { + "device_class": "door", + "payload_on": "Open", + "payload_off": "Closed" + } + }, + "PowerState": { + "component_type": "binary_sensor", + "payload_values": { + "device_class": "power" + } + }, + "RinseAidLack": { + "component_type": "binary_sensor", + "payload_values": { + "device_class": "problem", + "payload_on": "Present" + } + }, + "SaltLack": { + "component_type": "binary_sensor", + "payload_values": { + "device_class": "problem", + "payload_on": "Present" + } + }, + "RinseAidNearlyEmpty": { + "component_type": "binary_sensor", + "payload_values": { + "device_class": "problem", + "payload_on": "Present" + } + }, + "SaltNearlyEmpty": { + "component_type": "binary_sensor", + "payload_values": { + "device_class": "problem", + "payload_on": "Present" + } + }, + "WaterheaterCalcified": { + "component_type": "binary_sensor", + "payload_values": { + "device_class": "problem", + "payload_on": "Present" + } + }, +} + + def publish_ha_discovery(device, client, mqtt_topic): print(f"{now()} Publishing HA discovery for {device}") @@ -43,10 +111,17 @@ def publish_ha_discovery(device, client, mqtt_topic): available = feature.get("available", False) if (feature_type == "Setting" and available and (access == "read" or access == "readWrite")) or \ + (feature_type == "Status" and available and (access == "read" or access == "readWrite")) or \ feature_type == "Event" or \ feature_type == "Option": - component_type = "binary_sensor" if feature_type == "Event" else "sensor" # TODO use more appropriate types + default_component_type = "binary_sensor" if feature_type == "Event" else "sensor" # TODO use more appropriate types + + overrides = MAGIC_OVERRIDES.get(name, {}) + + component_type = overrides.get("component_type", default_component_type) + + extra_payload_values = overrides.get("payload_values", {}) discovery_topic = f"{HA_DISCOVERY_PREFIX}/{component_type}/hcpy/{device_ident}_{name}/config" # print(discovery_topic, state_topic) @@ -56,16 +131,17 @@ def publish_ha_discovery(device, client, mqtt_topic): "device": device_info, "state_topic": f"{mqtt_topic}/state", # "availability_topic": f"{mqtt_topic}/LWT", - "value_template": "{{value_json." + name + "}}", + "value_template": "{{value_json." + name + " | default('unavailable')}}", "object_id": f"{device_ident}_{name}", "unique_id": f"{device_ident}_{name}", + **extra_payload_values } if component_type == "binary_sensor": - discovery_payload["payload_on"] = "On" - discovery_payload["payload_off"] = "Off" + discovery_payload.setdefault("payload_on", "On") + discovery_payload.setdefault("payload_off", "Off") - print(discovery_topic) + # print(discovery_topic) # print(discovery_payload) client.publish(discovery_topic, json.dumps(discovery_payload), retain=True) From 5a2611967bbcb0e20c78f1f221465281583dec72 Mon Sep 17 00:00:00 2001 From: James Muscat Date: Sun, 11 Aug 2024 14:48:56 +0100 Subject: [PATCH 08/13] Move MQTT discovery information into devices.json. We inject some "magic" overrides when `devices.json` is created, rather than at runtime; this means that users can update the discovery information directly in their `devices.json` configuration rather than in the code. This way, they can change existing or add new discovery information without needing a code change. --- HADiscovery.py | 33 +++++++++++++++++++++++---------- hc-login.py | 3 ++- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/HADiscovery.py b/HADiscovery.py index 01b3c9d..cb9e378 100644 --- a/HADiscovery.py +++ b/HADiscovery.py @@ -12,8 +12,14 @@ def decamelcase(str): HA_DISCOVERY_PREFIX = "homeassistant" +# These "magic overrides" provide HA MQTT autodiscovery data that is injected +# into devices.json when `hc-login.py` is run; the data is then read from +# devices.json at runtime. +# +# Note: keys should be integer (not string) taken from a device's feature +# mapping. MAGIC_OVERRIDES = { - "BackendConnected": { + 5: { # BSH.Common.Status.BackendConnected "component_type": "binary_sensor", "payload_values": { "device_class": "connectivity", @@ -21,14 +27,14 @@ MAGIC_OVERRIDES = { "payload_off": False } }, - "SoftwareUpdateAvailable": { + 21: { # BSH.Common.Event.SoftwareUpdateAvailable "component_type": "binary_sensor", "payload_values": { "device_class": "update", "payload_on": "Present" } }, - "DoorState": { + 527: { # BSH.Common.Status.DoorState "component_type": "binary_sensor", "payload_values": { "device_class": "door", @@ -36,41 +42,41 @@ MAGIC_OVERRIDES = { "payload_off": "Closed" } }, - "PowerState": { + 539: { # BSH.Common.Setting.PowerState "component_type": "binary_sensor", "payload_values": { "device_class": "power" } }, - "RinseAidLack": { + 4612: { # Dishcare.Dishwasher.Event.WaterheaterCalcified "component_type": "binary_sensor", "payload_values": { "device_class": "problem", "payload_on": "Present" } }, - "SaltLack": { + 5624: { # Dishcare.Dishwasher.Event.SaltLack "component_type": "binary_sensor", "payload_values": { "device_class": "problem", "payload_on": "Present" } }, - "RinseAidNearlyEmpty": { + 5625: { # Dishcare.Dishwasher.Event.RinseAidLack "component_type": "binary_sensor", "payload_values": { "device_class": "problem", "payload_on": "Present" } }, - "SaltNearlyEmpty": { + 4626: { # Dishcare.Dishwasher.Event.SaltNearlyEmpty "component_type": "binary_sensor", "payload_values": { "device_class": "problem", "payload_on": "Present" } }, - "WaterheaterCalcified": { + 5627: { # Dishcare.Dishwasher.Event.RinseAidNearlyEmpty "component_type": "binary_sensor", "payload_values": { "device_class": "problem", @@ -80,6 +86,13 @@ MAGIC_OVERRIDES = { } +def augment_device_features(features): + for id, feature in features.items(): + if id in MAGIC_OVERRIDES: + feature["discovery"] = MAGIC_OVERRIDES[id] + return features + + def publish_ha_discovery(device, client, mqtt_topic): print(f"{now()} Publishing HA discovery for {device}") @@ -117,7 +130,7 @@ def publish_ha_discovery(device, client, mqtt_topic): default_component_type = "binary_sensor" if feature_type == "Event" else "sensor" # TODO use more appropriate types - overrides = MAGIC_OVERRIDES.get(name, {}) + overrides = feature.get("discovery", {}) component_type = overrides.get("component_type", default_component_type) diff --git a/hc-login.py b/hc-login.py index b43e9d5..7779bcd 100755 --- a/hc-login.py +++ b/hc-login.py @@ -17,6 +17,7 @@ from Crypto.Hash import SHA256 from Crypto.Random import get_random_bytes from HCxml2json import xml2json +from HADiscovery import augment_device_features # These two lines enable debugging at httplib level (requests->urllib3->http.client) # You will see the REQUEST, including HEADERS and DATA, and RESPONSE with HEADERS but without DATA. @@ -310,6 +311,6 @@ for app in account["data"]["homeAppliances"]: machine = xml2json(features, description) config["description"] = machine["description"] - config["features"] = machine["features"] + config["features"] = augment_device_features(machine["features"]) print(json.dumps(configs, indent=4)) From 50862902c4bce4be36da24e6ef2284ce7543a6ca Mon Sep 17 00:00:00 2001 From: James Muscat Date: Sun, 11 Aug 2024 15:51:00 +0100 Subject: [PATCH 09/13] Add overrides for most dishwasher features. --- HADiscovery.py | 164 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 161 insertions(+), 3 deletions(-) diff --git a/HADiscovery.py b/HADiscovery.py index cb9e378..1012f22 100644 --- a/HADiscovery.py +++ b/HADiscovery.py @@ -19,6 +19,13 @@ HA_DISCOVERY_PREFIX = "homeassistant" # Note: keys should be integer (not string) taken from a device's feature # mapping. MAGIC_OVERRIDES = { + 3: { # BSH.Common.Setting.AllowBackendConnection + "component_type": "binary_sensor", + "payload_values": { + "payload_on": True, + "payload_off": False + } + }, 5: { # BSH.Common.Status.BackendConnected "component_type": "binary_sensor", "payload_values": { @@ -34,6 +41,35 @@ MAGIC_OVERRIDES = { "payload_on": "Present" } }, + 523: { # BSH.Common.Status.RemoteControlActive + "component_type": "binary_sensor", + "payload_values": { + "payload_on": True, + "payload_off": False + } + }, + 524: { # BSH.Common.Status.RemoteControlStartAllowed + "component_type": "binary_sensor", + "payload_values": { + "payload_on": True, + "payload_off": False + } + }, + 524: { # BSH.Common.Setting.ChildLock + "component_type": "binary_sensor", + "payload_values": { + "device_class": "lock", + "payload_on": False, # Lock "on" means "unlocked" to HA + "payload_off": True, # Lock "off" means "locked" + } + }, + 525: { # BSH.Common.Event.AquaStopOccured + "component_type": "binary_sensor", + "payload_values": { + "device_class": "problem", + "payload_on": "Present" + } + }, 527: { # BSH.Common.Status.DoorState "component_type": "binary_sensor", "payload_values": { @@ -48,6 +84,91 @@ MAGIC_OVERRIDES = { "device_class": "power" } }, + 542: { # BSH.Common.Option.ProgramProgress + "payload_values": { + "unit_of_measurement": "%" + } + }, + 543: { # BSH.Common.Event.LowWaterPressure + "component_type": "binary_sensor", + "payload_values": { + "device_class": "problem", + "payload_on": "Present" + } + }, + 544: { # BSH.Common.Option.RemainingProgramTime + "payload_values": { + "unit_of_measurement": "s", + "device_class": "duration" + } + }, + 549: { # BSH.Common.Option.RemainingProgramTimeIsEstimated + "component_type": "binary_sensor", + "payload_values": { + "payload_on": True, + "payload_off": False + } + }, + 558: { # BSH.Common.Option.StartInRelative + "payload_values": { + "unit_of_measurement": "s", + "device_class": "duration" + } + }, + 4101: { # Dishcare.Dishwasher.Status.SilenceOnDemandRemainingTime + "payload_values": { + "unit_of_measurement": "s", + "device_class": "duration" + } + }, + 4103: { # Dishcare.Dishwasher.Status.EcoDryActive + "component_type": "binary_sensor", + "payload_values": { + "payload_on": True, + "payload_off": False + } + }, + 4382: { # Dishcare.Dishwasher.Status.SilenceOnDemandDefaultTime + "payload_values": { + "unit_of_measurement": "s", + "device_class": "duration" + } + }, + 4384: { # Dishcare.Dishwasher.Setting.SpeedOnDemand + "component_type": "binary_sensor", + "payload_values": { + "payload_on": True, + "payload_off": False + } + }, + 4608: { # Dishcare.Dishwasher.Event.InternalError + "component_type": "binary_sensor", + "payload_values": { + "device_class": "problem", + "payload_on": "Present" + } + }, + 4609: { # Dishcare.Dishwasher.Event.CheckFilterSystem + "component_type": "binary_sensor", + "payload_values": { + "device_class": "problem", + "payload_on": "Present" + } + }, + 4610: { # Dishcare.Dishwasher.Event.DrainingNotPossible + "component_type": "binary_sensor", + "payload_values": { + "device_class": "problem", + "payload_on": "Present" + } + }, + 4611: { # Dishcare.Dishwasher.Event.DrainPumpBlocked + "component_type": "binary_sensor", + "payload_values": { + "device_class": "problem", + "payload_on": "Present" + } + }, 4612: { # Dishcare.Dishwasher.Event.WaterheaterCalcified "component_type": "binary_sensor", "payload_values": { @@ -55,14 +176,21 @@ MAGIC_OVERRIDES = { "payload_on": "Present" } }, - 5624: { # Dishcare.Dishwasher.Event.SaltLack + 4613: { # Dishcare.Dishwasher.Event.LowVoltage "component_type": "binary_sensor", "payload_values": { "device_class": "problem", "payload_on": "Present" } }, - 5625: { # Dishcare.Dishwasher.Event.RinseAidLack + 4624: { # Dishcare.Dishwasher.Event.SaltLack + "component_type": "binary_sensor", + "payload_values": { + "device_class": "problem", + "payload_on": "Present" + } + }, + 4625: { # Dishcare.Dishwasher.Event.RinseAidLack "component_type": "binary_sensor", "payload_values": { "device_class": "problem", @@ -76,13 +204,41 @@ MAGIC_OVERRIDES = { "payload_on": "Present" } }, - 5627: { # Dishcare.Dishwasher.Event.RinseAidNearlyEmpty + 4627: { # Dishcare.Dishwasher.Event.RinseAidNearlyEmpty "component_type": "binary_sensor", "payload_values": { "device_class": "problem", "payload_on": "Present" } }, + 5121: { # Dishcare.Dishwasher.Option.ExtraDry + "component_type": "binary_sensor", + "payload_values": { + "payload_on": True, + "payload_off": False + } + }, + 5124: { # Dishcare.Dishwasher.Option.HalfLoad + "component_type": "binary_sensor", + "payload_values": { + "payload_on": True, + "payload_off": False + } + }, + 5127: { # Dishcare.Dishwasher.Option.VarioSpeedPlus + "component_type": "binary_sensor", + "payload_values": { + "payload_on": True, + "payload_off": False + } + }, + 5136: { # Dishcare.Dishwasher.Option.SilenceOnDemand + "component_type": "binary_sensor", + "payload_values": { + "payload_on": True, + "payload_off": False + } + }, } @@ -90,6 +246,8 @@ def augment_device_features(features): for id, feature in features.items(): if id in MAGIC_OVERRIDES: feature["discovery"] = MAGIC_OVERRIDES[id] + # else: + # print(f"No discovery information for: {id} => {feature['name']}", file=sys.stderr) return features From be28bc942b34fa99b3bf2dbd0e12e84a3e049b3a Mon Sep 17 00:00:00 2001 From: James Muscat Date: Sun, 11 Aug 2024 16:08:02 +0100 Subject: [PATCH 10/13] Add autodiscovery section to README. --- README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/README.md b/README.md index 9939abb..bdbe381 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ mqtt_cafile = None mqtt_certfile = None mqtt_keyfile = None mqtt_clientname="hcpy" +ha_discovery = True # See section on "Home Assistant autodiscovery" ``` ```bash @@ -456,3 +457,48 @@ To start a dishwasher on eco mode in 10 miuntes (`BSH.Common.Option.StartInRelat ## Notes - Sometimes when the device is off, there is the error `ERROR [ip] [Errno 113] No route to host` - There is a lot more information available, like the status of a program that is currently active. This needs to be integrated if possible. For now only the values that relate to the `config.json` are published + +## Home Assistant autodiscovery + +It's possible to allow Home Assistant to automatically discover the device using +MQTT auto-discovery messages. With `ha_discovery = True` in `config.ini` or by +passing `--ha-discovery` on the commandline, `hcpy` will publish +[HA discovery messages](https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery) +for each recognised property of your devices. + +### Limitations + +Discovery messages currently only contain a `state` topic, not a `command` topic, +so autodiscovered devices will be read-only. You can still use the method +described in `Posting to the appliance` above. + +### Customising the discovery messages + +`hcpy` will make some attempt to use the correct [component](https://www.home-assistant.io/integrations/mqtt/#configuration) +and for some devices set an appropriate [device class](https://www.home-assistant.io/integrations/binary_sensor/#device-class). +You can customise these by editing your `devices.json` to add or edit the +`discovery` section for each feature. For example: + +```json +[ + { // ... + "features": { + // ... + "549": { + "name": "BSH.Common.Option.RemainingProgramTimeIsEstimated", + "discovery": { + "component_type": "binary_sensor", + "payload_values": { + "payload_on": true, + "payload_off": false + } + } + } + } + } +] +``` + +You may include arbitrary `payload_values` that are included in the MQTT discovery +messages published when `hcpy` starts. Default values are set in `HADiscovery.py` +for known devices/attributes. No validation is performed against these values. From 5fc93f6c1c0c8b6485c1e9553f4f6dbdc147c1cc Mon Sep 17 00:00:00 2001 From: James Muscat Date: Sun, 11 Aug 2024 16:10:56 +0100 Subject: [PATCH 11/13] Correct definition for `RemoteControlStartAllowed`. --- HADiscovery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/HADiscovery.py b/HADiscovery.py index 1012f22..bd568b0 100644 --- a/HADiscovery.py +++ b/HADiscovery.py @@ -41,14 +41,14 @@ MAGIC_OVERRIDES = { "payload_on": "Present" } }, - 523: { # BSH.Common.Status.RemoteControlActive + 517: { # BSH.Common.Status.RemoteControlStartAllowed "component_type": "binary_sensor", "payload_values": { "payload_on": True, "payload_off": False } }, - 524: { # BSH.Common.Status.RemoteControlStartAllowed + 523: { # BSH.Common.Status.RemoteControlActive "component_type": "binary_sensor", "payload_values": { "payload_on": True, From f5994a6dc9154578364fee18a6c4f788e56965a7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 11 Aug 2024 15:41:12 +0000 Subject: [PATCH 12/13] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- HADiscovery.py | 196 +++++++++++++++---------------------------------- hc-login.py | 2 +- hc2mqtt.py | 4 +- 3 files changed, 61 insertions(+), 141 deletions(-) diff --git a/HADiscovery.py b/HADiscovery.py index bd568b0..55c3773 100644 --- a/HADiscovery.py +++ b/HADiscovery.py @@ -5,7 +5,7 @@ from HCSocket import now def decamelcase(str): - split = re.findall(r'[A-Z](?:[a-z]+|[A-Z]*(?=[A-Z]|$))', str) + split = re.findall(r"[A-Z](?:[a-z]+|[A-Z]*(?=[A-Z]|$))", str) return f"{split[0]} {' '.join(split[1:]).lower()}".strip() @@ -21,39 +21,27 @@ HA_DISCOVERY_PREFIX = "homeassistant" MAGIC_OVERRIDES = { 3: { # BSH.Common.Setting.AllowBackendConnection "component_type": "binary_sensor", - "payload_values": { - "payload_on": True, - "payload_off": False - } + "payload_values": {"payload_on": True, "payload_off": False}, }, 5: { # BSH.Common.Status.BackendConnected "component_type": "binary_sensor", "payload_values": { "device_class": "connectivity", "payload_on": True, - "payload_off": False - } + "payload_off": False, + }, }, 21: { # BSH.Common.Event.SoftwareUpdateAvailable "component_type": "binary_sensor", - "payload_values": { - "device_class": "update", - "payload_on": "Present" - } + "payload_values": {"device_class": "update", "payload_on": "Present"}, }, 517: { # BSH.Common.Status.RemoteControlStartAllowed "component_type": "binary_sensor", - "payload_values": { - "payload_on": True, - "payload_off": False - } + "payload_values": {"payload_on": True, "payload_off": False}, }, 523: { # BSH.Common.Status.RemoteControlActive "component_type": "binary_sensor", - "payload_values": { - "payload_on": True, - "payload_off": False - } + "payload_values": {"payload_on": True, "payload_off": False}, }, 524: { # BSH.Common.Setting.ChildLock "component_type": "binary_sensor", @@ -61,183 +49,104 @@ MAGIC_OVERRIDES = { "device_class": "lock", "payload_on": False, # Lock "on" means "unlocked" to HA "payload_off": True, # Lock "off" means "locked" - } + }, }, 525: { # BSH.Common.Event.AquaStopOccured "component_type": "binary_sensor", - "payload_values": { - "device_class": "problem", - "payload_on": "Present" - } + "payload_values": {"device_class": "problem", "payload_on": "Present"}, }, 527: { # BSH.Common.Status.DoorState "component_type": "binary_sensor", - "payload_values": { - "device_class": "door", - "payload_on": "Open", - "payload_off": "Closed" - } + "payload_values": {"device_class": "door", "payload_on": "Open", "payload_off": "Closed"}, }, 539: { # BSH.Common.Setting.PowerState "component_type": "binary_sensor", - "payload_values": { - "device_class": "power" - } - }, - 542: { # BSH.Common.Option.ProgramProgress - "payload_values": { - "unit_of_measurement": "%" - } + "payload_values": {"device_class": "power"}, }, + 542: {"payload_values": {"unit_of_measurement": "%"}}, # BSH.Common.Option.ProgramProgress 543: { # BSH.Common.Event.LowWaterPressure "component_type": "binary_sensor", - "payload_values": { - "device_class": "problem", - "payload_on": "Present" - } + "payload_values": {"device_class": "problem", "payload_on": "Present"}, }, 544: { # BSH.Common.Option.RemainingProgramTime - "payload_values": { - "unit_of_measurement": "s", - "device_class": "duration" - } + "payload_values": {"unit_of_measurement": "s", "device_class": "duration"} }, 549: { # BSH.Common.Option.RemainingProgramTimeIsEstimated "component_type": "binary_sensor", - "payload_values": { - "payload_on": True, - "payload_off": False - } + "payload_values": {"payload_on": True, "payload_off": False}, }, 558: { # BSH.Common.Option.StartInRelative - "payload_values": { - "unit_of_measurement": "s", - "device_class": "duration" - } + "payload_values": {"unit_of_measurement": "s", "device_class": "duration"} }, 4101: { # Dishcare.Dishwasher.Status.SilenceOnDemandRemainingTime - "payload_values": { - "unit_of_measurement": "s", - "device_class": "duration" - } + "payload_values": {"unit_of_measurement": "s", "device_class": "duration"} }, 4103: { # Dishcare.Dishwasher.Status.EcoDryActive "component_type": "binary_sensor", - "payload_values": { - "payload_on": True, - "payload_off": False - } + "payload_values": {"payload_on": True, "payload_off": False}, }, 4382: { # Dishcare.Dishwasher.Status.SilenceOnDemandDefaultTime - "payload_values": { - "unit_of_measurement": "s", - "device_class": "duration" - } + "payload_values": {"unit_of_measurement": "s", "device_class": "duration"} }, 4384: { # Dishcare.Dishwasher.Setting.SpeedOnDemand "component_type": "binary_sensor", - "payload_values": { - "payload_on": True, - "payload_off": False - } + "payload_values": {"payload_on": True, "payload_off": False}, }, 4608: { # Dishcare.Dishwasher.Event.InternalError "component_type": "binary_sensor", - "payload_values": { - "device_class": "problem", - "payload_on": "Present" - } + "payload_values": {"device_class": "problem", "payload_on": "Present"}, }, 4609: { # Dishcare.Dishwasher.Event.CheckFilterSystem "component_type": "binary_sensor", - "payload_values": { - "device_class": "problem", - "payload_on": "Present" - } + "payload_values": {"device_class": "problem", "payload_on": "Present"}, }, 4610: { # Dishcare.Dishwasher.Event.DrainingNotPossible "component_type": "binary_sensor", - "payload_values": { - "device_class": "problem", - "payload_on": "Present" - } + "payload_values": {"device_class": "problem", "payload_on": "Present"}, }, 4611: { # Dishcare.Dishwasher.Event.DrainPumpBlocked "component_type": "binary_sensor", - "payload_values": { - "device_class": "problem", - "payload_on": "Present" - } + "payload_values": {"device_class": "problem", "payload_on": "Present"}, }, 4612: { # Dishcare.Dishwasher.Event.WaterheaterCalcified "component_type": "binary_sensor", - "payload_values": { - "device_class": "problem", - "payload_on": "Present" - } + "payload_values": {"device_class": "problem", "payload_on": "Present"}, }, 4613: { # Dishcare.Dishwasher.Event.LowVoltage "component_type": "binary_sensor", - "payload_values": { - "device_class": "problem", - "payload_on": "Present" - } + "payload_values": {"device_class": "problem", "payload_on": "Present"}, }, 4624: { # Dishcare.Dishwasher.Event.SaltLack "component_type": "binary_sensor", - "payload_values": { - "device_class": "problem", - "payload_on": "Present" - } + "payload_values": {"device_class": "problem", "payload_on": "Present"}, }, 4625: { # Dishcare.Dishwasher.Event.RinseAidLack "component_type": "binary_sensor", - "payload_values": { - "device_class": "problem", - "payload_on": "Present" - } + "payload_values": {"device_class": "problem", "payload_on": "Present"}, }, 4626: { # Dishcare.Dishwasher.Event.SaltNearlyEmpty "component_type": "binary_sensor", - "payload_values": { - "device_class": "problem", - "payload_on": "Present" - } + "payload_values": {"device_class": "problem", "payload_on": "Present"}, }, 4627: { # Dishcare.Dishwasher.Event.RinseAidNearlyEmpty "component_type": "binary_sensor", - "payload_values": { - "device_class": "problem", - "payload_on": "Present" - } + "payload_values": {"device_class": "problem", "payload_on": "Present"}, }, 5121: { # Dishcare.Dishwasher.Option.ExtraDry "component_type": "binary_sensor", - "payload_values": { - "payload_on": True, - "payload_off": False - } + "payload_values": {"payload_on": True, "payload_off": False}, }, 5124: { # Dishcare.Dishwasher.Option.HalfLoad "component_type": "binary_sensor", - "payload_values": { - "payload_on": True, - "payload_off": False - } + "payload_values": {"payload_on": True, "payload_off": False}, }, 5127: { # Dishcare.Dishwasher.Option.VarioSpeedPlus "component_type": "binary_sensor", - "payload_values": { - "payload_on": True, - "payload_off": False - } + "payload_values": {"payload_on": True, "payload_off": False}, }, 5136: { # Dishcare.Dishwasher.Option.SilenceOnDemand "component_type": "binary_sensor", - "payload_values": { - "payload_on": True, - "payload_off": False - } + "payload_values": {"payload_on": True, "payload_off": False}, }, } @@ -259,11 +168,8 @@ def publish_ha_discovery(device, client, mqtt_topic): device_description = device.get("description", {}) version_parts = filter( - lambda d : d is not None, - [ - device_description.get("version"), - device_description.get("revision") - ] + lambda d: d is not None, + [device_description.get("version"), device_description.get("revision")], ) device_info = { @@ -271,7 +177,7 @@ def publish_ha_discovery(device, client, mqtt_topic): "name": device_name, "manufacturer": device_description.get("brand"), "model": device_description.get("model"), - "sw_version": ".".join(version_parts) + "sw_version": ".".join(version_parts), } for feature in device["features"].values(): @@ -281,12 +187,24 @@ def publish_ha_discovery(device, client, mqtt_topic): access = feature.get("access", "none") available = feature.get("available", False) - if (feature_type == "Setting" and available and (access == "read" or access == "readWrite")) or \ - (feature_type == "Status" and available and (access == "read" or access == "readWrite")) or \ - feature_type == "Event" or \ - feature_type == "Option": + if ( + ( + feature_type == "Setting" + and available + and (access == "read" or access == "readWrite") + ) + or ( + feature_type == "Status" + and available + and (access == "read" or access == "readWrite") + ) + or feature_type == "Event" + or feature_type == "Option" + ): - default_component_type = "binary_sensor" if feature_type == "Event" else "sensor" # TODO use more appropriate types + default_component_type = ( + "binary_sensor" if feature_type == "Event" else "sensor" + ) # TODO use more appropriate types overrides = feature.get("discovery", {}) @@ -294,7 +212,9 @@ def publish_ha_discovery(device, client, mqtt_topic): extra_payload_values = overrides.get("payload_values", {}) - discovery_topic = f"{HA_DISCOVERY_PREFIX}/{component_type}/hcpy/{device_ident}_{name}/config" + discovery_topic = ( + f"{HA_DISCOVERY_PREFIX}/{component_type}/hcpy/{device_ident}_{name}/config" + ) # print(discovery_topic, state_topic) discovery_payload = { @@ -305,7 +225,7 @@ def publish_ha_discovery(device, client, mqtt_topic): "value_template": "{{value_json." + name + " | default('unavailable')}}", "object_id": f"{device_ident}_{name}", "unique_id": f"{device_ident}_{name}", - **extra_payload_values + **extra_payload_values, } if component_type == "binary_sensor": diff --git a/hc-login.py b/hc-login.py index 7779bcd..e30b477 100755 --- a/hc-login.py +++ b/hc-login.py @@ -16,8 +16,8 @@ from bs4 import BeautifulSoup from Crypto.Hash import SHA256 from Crypto.Random import get_random_bytes -from HCxml2json import xml2json from HADiscovery import augment_device_features +from HCxml2json import xml2json # These two lines enable debugging at httplib level (requests->urllib3->http.client) # You will see the REQUEST, including HEADERS and DATA, and RESPONSE with HEADERS but without DATA. diff --git a/hc2mqtt.py b/hc2mqtt.py index 32b96ab..8d4f1d2 100755 --- a/hc2mqtt.py +++ b/hc2mqtt.py @@ -11,9 +11,9 @@ import click import click_config_file import paho.mqtt.client as mqtt +from HADiscovery import publish_ha_discovery from HCDevice import HCDevice from HCSocket import HCSocket, now -from HADiscovery import publish_ha_discovery @click.command() @@ -46,7 +46,7 @@ def hc2mqtt( mqtt_clientname: str, domain_suffix: str, debug: bool, - ha_discovery: bool + ha_discovery: bool, ): def on_connect(client, userdata, flags, rc): From be781c94bd5620fdb274f6caa38772d365a27d69 Mon Sep 17 00:00:00 2001 From: James Muscat Date: Sun, 11 Aug 2024 16:20:10 +0100 Subject: [PATCH 13/13] Update comments. --- HADiscovery.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/HADiscovery.py b/HADiscovery.py index 55c3773..f4d961f 100644 --- a/HADiscovery.py +++ b/HADiscovery.py @@ -202,9 +202,7 @@ def publish_ha_discovery(device, client, mqtt_topic): or feature_type == "Option" ): - default_component_type = ( - "binary_sensor" if feature_type == "Event" else "sensor" - ) # TODO use more appropriate types + default_component_type = "binary_sensor" if feature_type == "Event" else "sensor" overrides = feature.get("discovery", {}) @@ -222,6 +220,11 @@ def publish_ha_discovery(device, client, mqtt_topic): "device": device_info, "state_topic": f"{mqtt_topic}/state", # "availability_topic": f"{mqtt_topic}/LWT", + # # I found the behaviour of `availability_topic` unsatisfactory - + # # since it would set all device attributes to "unavailable" + # # then back to their correct values on every disconnect/ + # # reconnect. This leaves a lot of noise in the HA history, so + # # I felt things were better off without an `availability_topic`. "value_template": "{{value_json." + name + " | default('unavailable')}}", "object_id": f"{device_ident}_{name}", "unique_id": f"{device_ident}_{name}",