From 18304c5fc23b0534332a06fc8356dc7ff2540a88 Mon Sep 17 00:00:00 2001 From: Kevin Temming Date: Fri, 7 Jul 2023 15:44:43 +0200 Subject: [PATCH 1/2] 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/2] 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