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/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 fbcba56..364504c 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ and should prevent most any random attacker on your network from being able to ## Setup To avoid running into issues later with your default python installs, it's recommended to use a py virtual env for doing this. Go to your desired test directory, and: -``` +```bash python3 -m venv venv source venv/bin/activate git clone https://github.com/osresearch/hcpy @@ -163,7 +163,6 @@ Example message published to `homeconnect/dishwasher`: 'SilenceOnDemand': False } ``` - ### Clothes washer @@ -256,14 +255,13 @@ Example message published to `homeconnect/washer`: 'SelectedProgram': 28718 } ``` - ### Coffee Machine ![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 @@ -361,9 +359,70 @@ The coffee machine needs a better mapping to MQTT messages. 'ProcessPhase': 'None' } ``` -
+## 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 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 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 fcaff7b..2b0d267 100755 --- a/hc2mqtt +++ b/hc2mqtt @@ -12,81 +12,118 @@ 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") @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") +@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_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=} {mqtt_clientname=}") with open(config_file, "r") as f: devices = json.load(f) - client = mqtt.Client() - client.connect(host=mqtt_host, port=1883, keepalive=70) + client = mqtt.Client(mqtt_clientname) + + 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 = { - "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): + 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[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) + 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}") + client.subscribe(mqtt_set_topic) + client.on_message = on_message 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)) + 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(), msg) + 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 @@ -94,7 +131,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: