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 diff --git a/HADiscovery.py b/HADiscovery.py new file mode 100644 index 0000000..f4d961f --- /dev/null +++ b/HADiscovery.py @@ -0,0 +1,241 @@ +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" + + +# 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 = { + 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": { + "device_class": "connectivity", + "payload_on": True, + "payload_off": False, + }, + }, + 21: { # BSH.Common.Event.SoftwareUpdateAvailable + "component_type": "binary_sensor", + "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}, + }, + 523: { # BSH.Common.Status.RemoteControlActive + "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": {"device_class": "door", "payload_on": "Open", "payload_off": "Closed"}, + }, + 539: { # BSH.Common.Setting.PowerState + "component_type": "binary_sensor", + "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"}, + }, + 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": {"device_class": "problem", "payload_on": "Present"}, + }, + 4613: { # Dishcare.Dishwasher.Event.LowVoltage + "component_type": "binary_sensor", + "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"}, + }, + 4625: { # Dishcare.Dishwasher.Event.RinseAidLack + "component_type": "binary_sensor", + "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"}, + }, + 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}, + }, +} + + +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 + + +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(): + 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 ( + ( + 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" + + overrides = feature.get("discovery", {}) + + 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) + + discovery_payload = { + "name": decamelcase(name), + "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}", + **extra_payload_values, + } + + if component_type == "binary_sensor": + discovery_payload.setdefault("payload_on", "On") + discovery_payload.setdefault("payload_off", "Off") + + # print(discovery_topic) + # print(discovery_payload) + + client.publish(discovery_topic, json.dumps(discovery_payload), retain=True) 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. diff --git a/hc-login.py b/hc-login.py index b43e9d5..e30b477 100755 --- a/hc-login.py +++ b/hc-login.py @@ -16,6 +16,7 @@ from bs4 import BeautifulSoup from Crypto.Hash import SHA256 from Crypto.Random import get_random_bytes +from HADiscovery import augment_device_features from HCxml2json import xml2json # These two lines enable debugging at httplib level (requests->urllib3->http.client) @@ -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)) diff --git a/hc2mqtt.py b/hc2mqtt.py index 53ee0b5..8d4f1d2 100755 --- a/hc2mqtt.py +++ b/hc2mqtt.py @@ -11,6 +11,7 @@ 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 @@ -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,7 +151,7 @@ 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) )