From 18304c5fc23b0534332a06fc8356dc7ff2540a88 Mon Sep 17 00:00:00 2001 From: Kevin Temming Date: Fri, 7 Jul 2023 15:44:43 +0200 Subject: [PATCH 1/6] Allow for posting to appliance --- HCDevice.py | 82 ++++++++++++++++++++++++++++++++++++++++++++--------- README.md | 44 ++++++++++++++++++++++++++++ hc2mqtt | 55 +++++++++++++++++++++++++++++------ 3 files changed, 158 insertions(+), 23 deletions(-) diff --git a/HCDevice.py b/HCDevice.py index 03912a8..9ee46e4 100755 --- a/HCDevice.py +++ b/HCDevice.py @@ -54,7 +54,7 @@ def now(): return datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") class HCDevice: - def __init__(self, ws, features): + def __init__(self, ws, features, name): self.ws = ws self.features = features self.session_id = None @@ -62,13 +62,14 @@ class HCDevice: self.device_name = "hcpy" self.device_id = "0badcafe" self.debug = False + self.name = name def parse_values(self, values): if not self.features: return values result = {} - + for msg in values: uid = str(msg["uid"]) value = msg["value"] @@ -92,19 +93,62 @@ class HCDevice: return result + # Test the feature of an appliance agains a data object + def test_feature(self, data): + if 'uid' not in data: + raise Exception("{self.name}. Unable to configure appliance. UID is required.") + + if isinstance(data['uid'], int) == False: + raise Exception("{self.name}. Unable to configure appliance. UID must be an integer.") + + if 'value' not in data: + raise Exception("{self.name}. Unable to configure appliance. Value is required.") + + # Check if the uid is present for this appliance + uid = str(data['uid']) + if uid not in self.features: + raise Exception(f"{self.name}. Unable to configure appliance. UID {uid} is not valid.") + + feature = self.features[uid] + + # check the access level of the feature + print(now(), self.name, f"Processing feature {feature['name']} with uid {uid}") + if 'access' not in feature: + raise Exception(f"{self.name}. Unable to configure appliance. Feature {feature['name']} with uid {uid} does not have access.") + + access = feature['access'].lower() + if access != 'readwrite' and access != 'writeonly': + raise Exception(f"{self.name}. Unable to configure appliance. Feature {feature['name']} with uid {uid} has got access {feature['access']}.") + + # check if selected list with values is allowed + if 'values' in feature: + if isinstance(data['value'], int) == False: + raise Exception(f"Unable to configure appliance. The value {data['value']} must be an integer. Allowed values are {feature['values']}.") + value = str(data['value']) # values are strings in the feature list, but always seem to be an integer. An integer must be provided + if value not in feature['values']: + raise Exception(f"{self.name}. Unable to configure appliance. Value {data['value']} is not a valid value. Allowed values are {feature['values']}.") + + if 'min' in feature: + min = int(feature['min']) + max = int(feature['min']) + if isinstance(data['value'], int) == False or data['value'] < min or data['value'] > max: + raise Exception(f"{self.name}. Unable to configure appliance. Value {data['value']} is not a valid value. The value must be an integer in the range {min} and {max}.") + + return True + def recv(self): try: buf = self.ws.recv() if buf is None: return None except Exception as e: - print("receive error", e, traceback.format_exc()) + print(self.name, "receive error", e, traceback.format_exc()) return None try: return self.handle_message(buf) except Exception as e: - print("error handling msg", e, buf, traceback.format_exc()) + print(self.name, "error handling msg", e, buf, traceback.format_exc()) return None # reply to a POST or GET message with new data @@ -129,25 +173,32 @@ class HCDevice: } if data is not None: - msg["data"] = [data] + if action == "POST": + if self.test_feature(data) != True: + return + msg["data"] = [data] + else: + msg["data"] = [data] - self.ws.send(msg) + try: + self.ws.send(msg) + except Exception as e: + print(self.name, "Failed to send", e, msg, traceback.format_exc()) self.tx_msg_id += 1 def handle_message(self, buf): msg = json.loads(buf) if self.debug: - print(now(), "RX:", msg) + print(now(), self.name, "RX:", msg) sys.stdout.flush() - resource = msg["resource"] action = msg["action"] values = {} if "code" in msg: - #print(now(), "ERROR", msg["code"]) + print(now(), self.name, "ERROR", msg["code"]) values = { "error": msg["code"], "resource": msg.get("resource", ''), @@ -186,7 +237,7 @@ class HCDevice: self.get("/ro/allMandatoryValues") #self.get("/ro/values") else: - print(now(), "Unknown resource", resource, file=sys.stderr) + print(now(), self.name, "Unknown resource", resource, file=sys.stderr) elif action == "RESPONSE" or action == "NOTIFY": if resource == "/iz/info" or resource == "/ci/info": @@ -204,7 +255,10 @@ class HCDevice: elif resource == "/ro/allMandatoryValues" \ or resource == "/ro/values": - values = self.parse_values(msg["data"]) + if 'data' in msg: + values = self.parse_values(msg["data"]) + else: + print(now(), self.name, f"received {msg}") elif resource == "/ci/registeredDevices": # we don't care pass @@ -215,7 +269,7 @@ class HCDevice: self.services[service["service"]] = { "version": service["version"], } - #print(now(), "services", self.services) + #print(self.name, now(), "services", self.services) # we should figure out which ones to query now # if "iz" in self.services: @@ -227,8 +281,8 @@ class HCDevice: #self.get("/if/info") - else: - print(now(), "Unknown", msg) + else: + print(now(), self.name, "Unknown", msg) # return whatever we've parsed out of it return values diff --git a/README.md b/README.md index e7b5e9f..9cc4bb8 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,50 @@ The coffee machine needs a better mapping to MQTT messages. ``` +## Posting to the appliance + +Whereas the reading of de status is very beta, this is very very alpha. There is some basic error handling, but don't expect that everything will work. + +In your config file you can find items that contain `readWrite` or `writeOnly`, some of them contain values so you know what to provide, ie: + +```json +"539": { + "name": "BSH.Common.Setting.PowerState", + "access": "readWrite", + "available": "true", + "refCID": "03", + "refDID": "80", + "values": { + "2": "On", + "3": "Standby" + } +}, +``` + +With this information you can build the JSON object you can send over mqtt to change the power state + +Topic: `homeconnect/[devicename]/set`, ie `homeconnect/coffeemaker/set` + +Payload: + +```json +{"uid":539,"value":2} +``` +As for now, the results will be displayed by the script only, there is no response to an mqtt topic. + +There are properties that do not require predefined values, debugging is required to see what is needed. Here are some of those values found through debugging: + +Set the time: + +```json +{"uid":520,"value":"2023-07-07T15:01:21"} +``` + +Synchronize with time server, `false` is disabled + +```json +{"uid":547,"value":false} +``` ## FRIDA tools diff --git a/hc2mqtt b/hc2mqtt index 43d71ef..f8ba704 100755 --- a/hc2mqtt +++ b/hc2mqtt @@ -17,21 +17,41 @@ from HCSocket import HCSocket, now @click.argument("config_file") @click.option("-h", "--mqtt_host", default="localhost") @click.option("-p", "--mqtt_prefix", default="homeconnect/") -def hc2mqtt(config_file: str, mqtt_host: str, mqtt_prefix: str): - click.echo(f"Hello {config_file=} {mqtt_host=} {mqtt_prefix=}") +@click.option("--mqtt_port", default=1883, type=int) +@click.option("--mqtt_username") +@click.option("--mqtt_password") +@click.option("--mqtt_ssl", is_flag=True) +@click.option("--mqtt_cafile") +@click.option("--mqtt_certfile") +@click.option("--mqtt_keyfile") +def hc2mqtt(config_file: str, mqtt_host: str, mqtt_prefix: str, mqtt_port: int, mqtt_username: str, + mqtt_password: str, mqtt_ssl: bool, mqtt_cafile: str, mqtt_certfile: str, mqtt_keyfile: str): + click.echo(f"Hello {config_file=} {mqtt_host=} {mqtt_prefix=} {mqtt_port=} {mqtt_username=} {mqtt_password=} " + f"{mqtt_ssl=} {mqtt_cafile=} {mqtt_certfile=} {mqtt_keyfile=}") with open(config_file, "r") as f: devices = json.load(f) client = mqtt.Client() - client.connect(host=mqtt_host, port=1883, keepalive=70) + + if mqtt_username and mqtt_password: + client.username_pw_set(mqtt_username, mqtt_password) + + if mqtt_ssl: + if mqtt_cafile and mqtt_certfile and mqtt_keyfile: + client.tls_set(ca_certs=mqtt_cafile, certfile=mqtt_certfile, keyfile=mqtt_keyfile, cert_reqs=ssl.CERT_REQUIRED) + else: + client.tls_set(cert_reqs=ssl.CERT_NONE) + + client.connect(host=mqtt_host, port=mqtt_port, keepalive=70) for device in devices: mqtt_topic = mqtt_prefix + device["name"] + print(now(), f"topic: {mqtt_topic}") thread = Thread(target=client_connect, args=(client, device, mqtt_topic)) thread.start() - + client.loop_forever() # Map their value names to easier state names topics = { @@ -45,19 +65,36 @@ topics = { "FatalErrorOccured": "error", } - - def client_connect(client, device, mqtt_topic): + def on_message(client, userdata, msg): + global dev + mqtt_state = msg.payload.decode() + print(now(),f"received mqtt message {mqtt_state}") + try: + msg = json.loads(mqtt_state) + if 'uid' in msg: + dev.get("/ro/values",1,"POST",msg) + else: + raise Exception(f"Payload {msg} is not correctly formatted") + except Exception as e: + print("ERROR", e, file=sys.stderr) + + global dev host = device["host"] state = {} for topic in topics: state[topics[topic]] = None + mqtt_set_topic = mqtt_topic + "/set" + print(now(), device["name"], f"set topic: {mqtt_set_topic}") + client.subscribe(mqtt_set_topic) + client.on_message = on_message + while True: try: ws = HCSocket(host, device["key"], device.get("iv",None)) - dev = HCDevice(ws, device.get("features", None)) + dev = HCDevice(ws, device.get("features", None), device["name"]) #ws.debug = True ws.reconnect() @@ -67,7 +104,7 @@ def client_connect(client, device, mqtt_topic): if msg is None: break if len(msg) > 0: - print(now(), msg) + print(now(), device["name"], msg) update = False for topic in topics: @@ -93,7 +130,7 @@ def client_connect(client, device, mqtt_topic): continue msg = json.dumps(state) - print("publish", mqtt_topic, msg) + print(now(), device["name"], f"publish to {mqtt_topic} with {msg}") client.publish(mqtt_topic + "/state", msg) except Exception as e: From b7dae0a7c41033fe28be8d3a5e62d30979d03a79 Mon Sep 17 00:00:00 2001 From: Kevin Temming Date: Tue, 12 Sep 2023 23:24:11 +0200 Subject: [PATCH 2/6] 2023.9.12.1 --- .vscode/settings.json | 5 ++++ README.md | 51 +++++++++++++----------------------- changelog.md | 13 ++++++++++ hc2mqtt | 60 +++++++++++++++++++++---------------------- 4 files changed, 66 insertions(+), 63 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 changelog.md diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a490b61 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "githubPullRequests.ignoredPullRequestBranches": [ + "main" + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 9cc4bb8..d36f403 100644 --- a/README.md +++ b/README.md @@ -71,22 +71,7 @@ library. Example message published to `homeconnect/dishwasher`: -``` -{ - "state": "Run", - "door": "Closed", - "remaining": "2:49", - "power": true, - "lowwaterpressure": false, - "aquastop": false, - "error": false, - "remainingseconds": 10140 -} -``` -
-Full state information - ``` { 'AllowBackendConnection': False, @@ -154,7 +139,6 @@ Example message published to `homeconnect/dishwasher`: ```
- ### Clothes washer ![laptop in a clothes washer](images/clotheswasher.jpg) @@ -167,21 +151,7 @@ binary data over the websocket (type 0x82). Example message published to `homeconnect/washer`: -``` -{ - "state": "Ready", - "door": "Closed", - "remaining": "3:48", - "power": true, - "lowwaterpressure": false, - "aquastop": false, - "error": false, - "remainingseconds": 13680 -} -``` -
-Full state information ``` { @@ -252,11 +222,9 @@ Example message published to `homeconnect/washer`: ![Image of the coffee machine from the Siemens website](images/coffee.jpg) -The coffee machine needs a better mapping to MQTT messages. +Example message published to `homeconnect/coffeemaker`:
-Full state information - ``` { 'LastSelectedBeverage': 8217, @@ -400,3 +368,20 @@ Synchronize with time server, `false` is disabled ## FRIDA tools Moved to [`README-frida.md`](README-frida.md) + +## Home assistant + +For integration with Home Assistant, the following MQTT sensor can be used to create a read only sensor + +```yaml +- unique_id: "coffee_machine" + name: "Coffee Machine" + state_topic: "homeconnect/coffeemaker/state" + value_template: "{{ value_json.PowerState }}" + json_attributes_topic: "homeconnect/coffeemaker/state" + json_attributes_template: "{{ value_json | tojson }}" +``` + +## 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 \ No newline at end of file diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..62f0807 --- /dev/null +++ b/changelog.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## 2023.9.12.1 +### Added +- Ability to configure MQTT clientname + +### Changed +- There was a default set of values being published. Now the device publishes what is present as access read, or readWrite in the `config.json` + +### Fixed +- MQTT was not always published to the correct topic diff --git a/hc2mqtt b/hc2mqtt index f8ba704..6a6f357 100755 --- a/hc2mqtt +++ b/hc2mqtt @@ -12,7 +12,6 @@ import paho.mqtt.client as mqtt from HCDevice import HCDevice from HCSocket import HCSocket, now - @click.command() @click.argument("config_file") @click.option("-h", "--mqtt_host", default="localhost") @@ -24,15 +23,16 @@ from HCSocket import HCSocket, now @click.option("--mqtt_cafile") @click.option("--mqtt_certfile") @click.option("--mqtt_keyfile") +@click.option("--mqtt_clientname") def hc2mqtt(config_file: str, mqtt_host: str, mqtt_prefix: str, mqtt_port: int, mqtt_username: str, - mqtt_password: str, mqtt_ssl: bool, mqtt_cafile: str, mqtt_certfile: str, mqtt_keyfile: str): + mqtt_password: str, mqtt_ssl: bool, mqtt_cafile: str, mqtt_certfile: str, mqtt_keyfile: str, mqtt_clientname: str): click.echo(f"Hello {config_file=} {mqtt_host=} {mqtt_prefix=} {mqtt_port=} {mqtt_username=} {mqtt_password=} " - f"{mqtt_ssl=} {mqtt_cafile=} {mqtt_certfile=} {mqtt_keyfile=}") + f"{mqtt_ssl=} {mqtt_cafile=} {mqtt_certfile=} {mqtt_keyfile=} {mqtt_clientname=}") with open(config_file, "r") as f: devices = json.load(f) - client = mqtt.Client() + client = mqtt.Client(mqtt_clientname) if mqtt_username and mqtt_password: client.username_pw_set(mqtt_username, mqtt_password) @@ -55,36 +55,40 @@ def hc2mqtt(config_file: str, mqtt_host: str, mqtt_prefix: str, mqtt_port: int, # Map their value names to easier state names topics = { - "OperationState": "state", - "DoorState": "door", - "RemainingProgramTime": "remaining", - "PowerState": "power", - "LowWaterPressure": "lowwaterpressure", - "AquaStopOccured": "aquastop", - "InternalError": "error", - "FatalErrorOccured": "error", + "InternalError": "Error", + "FatalErrorOccured": "Error", } +global dev +dev = {} def client_connect(client, device, mqtt_topic): def on_message(client, userdata, msg): - global dev + print(msg.topic) mqtt_state = msg.payload.decode() + mqtt_topic = msg.topic.split('/') print(now(),f"received mqtt message {mqtt_state}") try: msg = json.loads(mqtt_state) if 'uid' in msg: - dev.get("/ro/values",1,"POST",msg) + dev[mqtt_topic[-2]].get("/ro/values",1,"POST",msg) else: raise Exception(f"Payload {msg} is not correctly formatted") except Exception as e: print("ERROR", e, file=sys.stderr) - global dev host = device["host"] + device_topics = topics + + for value in device["features"]: + if "access" in device["features"][value] and "read" in device["features"][value]['access'].lower(): + name = device["features"][value]['name'].split(".") + device_topics[name[-1]] = name[-1] + device_topics[value] = name[-1] #sometimes the returned key is a digit, making translation possible state = {} - for topic in topics: - state[topics[topic]] = None + for topic in device_topics: + if not topic.isdigit(): #We only want the named topics + state[device_topics[topic]] = None mqtt_set_topic = mqtt_topic + "/set" print(now(), device["name"], f"set topic: {mqtt_set_topic}") @@ -93,36 +97,32 @@ def client_connect(client, device, mqtt_topic): while True: try: + print(now(), device["name"], f"connecting to {host}") ws = HCSocket(host, device["key"], device.get("iv",None)) - dev = HCDevice(ws, device.get("features", None), device["name"]) + dev[device["name"]] = HCDevice(ws, device.get("features", None), device["name"]) #ws.debug = True ws.reconnect() while True: - msg = dev.recv() + msg = dev[device["name"]].recv() if msg is None: break if len(msg) > 0: print(now(), device["name"], msg) update = False - for topic in topics: + for topic in device_topics: value = msg.get(topic, None) if value is None: continue - # Convert "On" to True, "Off" to False - if value == "On": - value = True - elif value == "Off": - value = False - - new_topic = topics[topic] - if new_topic == "remaining": - state["remainingseconds"] = value - value = "%d:%02d" % (value / 60 / 60, (value / 60) % 60) + # new_topic = topics[topic] + # if new_topic == "remaining": + # state["remainingseconds"] = value + # value = "%d:%02d" % (value / 60 / 60, (value / 60) % 60) + new_topic = device_topics[topic] state[new_topic] = value update = True From 10dfc3df132e685ef7db91ec3a1a64e997e2e2f9 Mon Sep 17 00:00:00 2001 From: Meatballs Date: Tue, 19 Mar 2024 10:58:51 +0000 Subject: [PATCH 3/6] Add a default clientname for mqtt --- hc2mqtt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hc2mqtt b/hc2mqtt index 2b0d267..c6f4d81 100755 --- a/hc2mqtt +++ b/hc2mqtt @@ -23,7 +23,7 @@ from HCSocket import HCSocket, now @click.option("--mqtt_cafile") @click.option("--mqtt_certfile") @click.option("--mqtt_keyfile") -@click.option("--mqtt_clientname") +@click.option("--mqtt_clientname", default="hcpy") def hc2mqtt(config_file: str, mqtt_host: str, mqtt_prefix: str, mqtt_port: int, mqtt_username: str, mqtt_password: str, mqtt_ssl: bool, mqtt_cafile: str, mqtt_certfile: str, mqtt_keyfile: str, mqtt_clientname: str): From bbd5c468c1c59bdbe7e23249f95a40cb5901c638 Mon Sep 17 00:00:00 2001 From: Meatballs Date: Tue, 19 Mar 2024 11:22:46 +0000 Subject: [PATCH 4/6] Fix incorrect min/max lookup --- HCDevice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HCDevice.py b/HCDevice.py index 9ee46e4..6c40921 100755 --- a/HCDevice.py +++ b/HCDevice.py @@ -130,7 +130,7 @@ class HCDevice: if 'min' in feature: min = int(feature['min']) - max = int(feature['min']) + max = int(feature['max']) if isinstance(data['value'], int) == False or data['value'] < min or data['value'] > max: raise Exception(f"{self.name}. Unable to configure appliance. Value {data['value']} is not a valid value. The value must be an integer in the range {min} and {max}.") From e5550486e2fd45d612aa1a54ed420e42a7e8fd9b Mon Sep 17 00:00:00 2001 From: Meatballs1 Date: Tue, 19 Mar 2024 13:33:50 +0000 Subject: [PATCH 5/6] Resolve code review comment --- HCDevice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HCDevice.py b/HCDevice.py index 6c40921..c412c9a 100755 --- a/HCDevice.py +++ b/HCDevice.py @@ -98,7 +98,7 @@ class HCDevice: if 'uid' not in data: raise Exception("{self.name}. Unable to configure appliance. UID is required.") - if isinstance(data['uid'], int) == False: + if isinstance(data['uid'], int) is False: raise Exception("{self.name}. Unable to configure appliance. UID must be an integer.") if 'value' not in data: From 5307f1cf4062afc4d9dd9fd345abc2ac645be87a Mon Sep 17 00:00:00 2001 From: Meatballs1 Date: Tue, 19 Mar 2024 13:42:57 +0000 Subject: [PATCH 6/6] Resolves more code review comments --- HCDevice.py | 6 +++--- README.md | 2 +- hc2mqtt | 5 +++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/HCDevice.py b/HCDevice.py index c412c9a..eb2c520 100755 --- a/HCDevice.py +++ b/HCDevice.py @@ -122,7 +122,7 @@ class HCDevice: # check if selected list with values is allowed if 'values' in feature: - if isinstance(data['value'], int) == False: + if isinstance(data['value'], int) is False: raise Exception(f"Unable to configure appliance. The value {data['value']} must be an integer. Allowed values are {feature['values']}.") value = str(data['value']) # values are strings in the feature list, but always seem to be an integer. An integer must be provided if value not in feature['values']: @@ -131,7 +131,7 @@ class HCDevice: if 'min' in feature: min = int(feature['min']) max = int(feature['max']) - if isinstance(data['value'], int) == False or data['value'] < min or data['value'] > max: + if isinstance(data['value'], int) is False or data['value'] < min or data['value'] > max: raise Exception(f"{self.name}. Unable to configure appliance. Value {data['value']} is not a valid value. The value must be an integer in the range {min} and {max}.") return True @@ -174,7 +174,7 @@ class HCDevice: if data is not None: if action == "POST": - if self.test_feature(data) != True: + if self.test_feature(data) is False: return msg["data"] = [data] else: diff --git a/README.md b/README.md index 364504c..e76919f 100644 --- a/README.md +++ b/README.md @@ -363,7 +363,7 @@ Example message published to `homeconnect/coffeemaker`: ## Posting to the appliance -Whereas the reading of de status is very beta, this is very very alpha. There is some basic error handling, but don't expect that everything will work. +Whereas the reading of the status is very beta, this is very very alpha. There is some basic error handling, but don't expect that everything will work. In your config file you can find items that contain `readWrite` or `writeOnly`, some of them contain values so you know what to provide, ie: diff --git a/hc2mqtt b/hc2mqtt index c6f4d81..d6c1d4d 100755 --- a/hc2mqtt +++ b/hc2mqtt @@ -4,6 +4,7 @@ import json import sys import time +import ssl from threading import Thread import click @@ -67,11 +68,11 @@ def client_connect(client, device, mqtt_topic): print(msg.topic) mqtt_state = msg.payload.decode() mqtt_topic = msg.topic.split('/') - print(now(),f"received mqtt message {mqtt_state}") + print(now(), f"received mqtt message {mqtt_state}") try: msg = json.loads(mqtt_state) if 'uid' in msg: - dev[mqtt_topic[-2]].get("/ro/values",1,"POST",msg) + dev[mqtt_topic[-2]].get("/ro/values", 1, "POST", msg) else: raise Exception(f"Payload {msg} is not correctly formatted") except Exception as e: