Merge pull request #86 from jamesremuscat/feature/ha-discovery
Add Home Assistant MQTT autodiscovery
This commit is contained in:
@@ -10,7 +10,7 @@ RUN apt-get update && \
|
|||||||
apt-get remove -y gcc python3-dev libssl-dev && \
|
apt-get remove -y gcc python3-dev libssl-dev && \
|
||||||
apt-get autoremove -y
|
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
|
RUN chmod a+x ./run.sh
|
||||||
|
|
||||||
|
|||||||
241
HADiscovery.py
Normal file
241
HADiscovery.py
Normal file
@@ -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)
|
||||||
46
README.md
46
README.md
@@ -79,6 +79,7 @@ mqtt_cafile = None
|
|||||||
mqtt_certfile = None
|
mqtt_certfile = None
|
||||||
mqtt_keyfile = None
|
mqtt_keyfile = None
|
||||||
mqtt_clientname="hcpy"
|
mqtt_clientname="hcpy"
|
||||||
|
ha_discovery = True # See section on "Home Assistant autodiscovery"
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -456,3 +457,48 @@ To start a dishwasher on eco mode in 10 miuntes (`BSH.Common.Option.StartInRelat
|
|||||||
## Notes
|
## Notes
|
||||||
- Sometimes when the device is off, there is the error `ERROR [ip] [Errno 113] No route to host`
|
- 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
|
- 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.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from bs4 import BeautifulSoup
|
|||||||
from Crypto.Hash import SHA256
|
from Crypto.Hash import SHA256
|
||||||
from Crypto.Random import get_random_bytes
|
from Crypto.Random import get_random_bytes
|
||||||
|
|
||||||
|
from HADiscovery import augment_device_features
|
||||||
from HCxml2json import xml2json
|
from HCxml2json import xml2json
|
||||||
|
|
||||||
# These two lines enable debugging at httplib level (requests->urllib3->http.client)
|
# 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)
|
machine = xml2json(features, description)
|
||||||
config["description"] = machine["description"]
|
config["description"] = machine["description"]
|
||||||
config["features"] = machine["features"]
|
config["features"] = augment_device_features(machine["features"])
|
||||||
|
|
||||||
print(json.dumps(configs, indent=4))
|
print(json.dumps(configs, indent=4))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import click
|
|||||||
import click_config_file
|
import click_config_file
|
||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
|
from HADiscovery import publish_ha_discovery
|
||||||
from HCDevice import HCDevice
|
from HCDevice import HCDevice
|
||||||
from HCSocket import HCSocket, now
|
from HCSocket import HCSocket, now
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ from HCSocket import HCSocket, now
|
|||||||
@click.option("--mqtt_clientname", default="hcpy1")
|
@click.option("--mqtt_clientname", default="hcpy1")
|
||||||
@click.option("--domain_suffix", default="")
|
@click.option("--domain_suffix", default="")
|
||||||
@click.option("--debug/--no-debug", default=False)
|
@click.option("--debug/--no-debug", default=False)
|
||||||
|
@click.option("--ha-discovery", is_flag=True)
|
||||||
@click_config_file.configuration_option()
|
@click_config_file.configuration_option()
|
||||||
def hc2mqtt(
|
def hc2mqtt(
|
||||||
devices_file: str,
|
devices_file: str,
|
||||||
@@ -44,6 +46,7 @@ def hc2mqtt(
|
|||||||
mqtt_clientname: str,
|
mqtt_clientname: str,
|
||||||
domain_suffix: str,
|
domain_suffix: str,
|
||||||
debug: bool,
|
debug: bool,
|
||||||
|
ha_discovery: bool,
|
||||||
):
|
):
|
||||||
|
|
||||||
def on_connect(client, userdata, flags, rc):
|
def on_connect(client, userdata, flags, rc):
|
||||||
@@ -74,6 +77,8 @@ def hc2mqtt(
|
|||||||
now(), device["name"], f"program topic: {mqtt_selected_program_topic}"
|
now(), device["name"], f"program topic: {mqtt_selected_program_topic}"
|
||||||
)
|
)
|
||||||
client.subscribe(mqtt_selected_program_topic)
|
client.subscribe(mqtt_selected_program_topic)
|
||||||
|
if ha_discovery:
|
||||||
|
publish_ha_discovery(device, client, mqtt_topic)
|
||||||
else:
|
else:
|
||||||
print(now(), f"ERROR MQTT connection failed: {rc}")
|
print(now(), f"ERROR MQTT connection failed: {rc}")
|
||||||
|
|
||||||
@@ -117,7 +122,7 @@ def hc2mqtt(
|
|||||||
f"Hello {devices_file=} {mqtt_host=} {mqtt_prefix=} "
|
f"Hello {devices_file=} {mqtt_host=} {mqtt_prefix=} "
|
||||||
f"{mqtt_port=} {mqtt_username=} {mqtt_password=} "
|
f"{mqtt_port=} {mqtt_username=} {mqtt_password=} "
|
||||||
f"{mqtt_ssl=} {mqtt_cafile=} {mqtt_certfile=} {mqtt_keyfile=} {mqtt_clientname=}"
|
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:
|
with open(devices_file, "r") as f:
|
||||||
@@ -146,7 +151,7 @@ def hc2mqtt(
|
|||||||
client.connect(host=mqtt_host, port=mqtt_port, keepalive=70)
|
client.connect(host=mqtt_host, port=mqtt_port, keepalive=70)
|
||||||
|
|
||||||
for device in devices:
|
for device in devices:
|
||||||
mqtt_topic = mqtt_prefix + device["name"]
|
mqtt_topic = mqtt_prefix + device["host"]
|
||||||
thread = Thread(
|
thread = Thread(
|
||||||
target=client_connect, args=(client, device, mqtt_topic, domain_suffix, debug)
|
target=client_connect, args=(client, device, mqtt_topic, domain_suffix, debug)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user