diff --git a/HCDevice.py b/HCDevice.py index 9ee46e4..237311d 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, name): + def __init__(self, ws, features, name, description): self.ws = ws self.features = features self.session_id = None @@ -63,6 +63,13 @@ def __init__(self, ws, features, name): self.device_id = "0badcafe" self.debug = False self.name = name + self.description = description + self.uids = {} #mapping of uids to features + for uid in self.features: + feature = self.features[uid] + feature_name = feature["name"] + self.uids[feature_name] = int(uid) + def parse_values(self, values): if not self.features: @@ -75,30 +82,74 @@ def parse_values(self, values): value = msg["value"] value_str = str(value) - name = uid +# name = uid status = None if uid in self.features: - status = self.features[uid] + status = self.features[uid] if status: - name = status["name"] +# name = status["name"] if "values" in status \ and value_str in status["values"]: value = status["values"][value_str] - - # trim everything off the name except the last part - name = re.sub(r'^.*\.', '', name) - result[name] = value +#keep communication to HCDevice uid based. Formatting to human-readable names in hc2mqtt +# # trim everything off the name except the last part +# name = re.sub(r'^.*\.', '', name) +# result[name] = value + result[uid] = value return result + + def get_featureUID(self, feature_name): + if feature_name not in self.uids: + raise Exception("'{}' unknown feature_name. No UID found.") + return self.uids[feature_name] + + + # Test the uid used for a program of an appliance against available programs + # and Setting "BSH.Common.Setting.RemoteControlLevel" + def test_and_reformat_program(self, data): + #example json data content: {"program":8196, "options":[{"uid":558,"value":0},{"uid":5123,"value":false},{"uid":5126,"value":false},{"uid":5127,"value":false}]} (thanks to @chris-mc1) + #TODO check on options + + if 'program' not in data: + raise Exception("{self.name}. Unable to configure appliance. 'program' is required.") + + if isinstance(data['program'], str) == True: + try: + data['program'] = int(data['program']) #try to transform into int + except Exception as e: + raise Exception("{self.name}. Unable to configure appliance. UID in 'program' must be an integer.") + elif isinstance(data['program'], int) == False: + raise Exception("{self.name}. Unable to configure appliance. UID in 'program' must be an integer.") + + # Check if the uid is a valid program for this appliance + uid = str(data['program']) + if uid not in self.features: + raise Exception(f"{self.name}. Unable to configure appliance. UID {uid} is not valid.") + feature = self.features[uid] + if ".Program." not in feature['name']: #check is valid for dishwasher. TODO: check other devices + raise Exception(f"{self.name}. Unable to configure appliance. UID {uid} is not a valid program.") + + if remoteControlStartAllowed is None or not remoteControlStartAllowed: #allow if none, if device has no remoteControlStartAllowed feature (or a different uid for it than used to detect remoteControlStartAllowed) + #since this is not watched by the device itself + raise Exception(f"{self.name}. Program not started. Remote access ist not activated on device. Check and change 'RemoteControlStartAllowed'.") + + return data # Test the feature of an appliance agains a data object - def test_feature(self, data): + def test_and_reformat_feature(self, data): + #example json data content: {'uid': 539, 'value': 2} 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'], str) == True: + try: + data['uid'] = int(data['uid']) #try to transform into int + except Exception as e: + raise Exception("{self.name}. Unable to configure appliance. UID must be an integer.") + elif isinstance(data['uid'], int) == False: raise Exception("{self.name}. Unable to configure appliance. UID must be an integer.") if 'value' not in data: @@ -122,11 +173,22 @@ def test_feature(self, data): # 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']}.") + value = None + if isinstance(data['value'], int): + #in difference to the comment below it has to be an integer (at least for dishwasher. TODO: check other devices) + #value = str(data['value']) # values are strings in the feature list, but always seem to be an integer. An integer must be provided + value = data['value'] + if str(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']}.") + elif isinstance(data['value'], str): + for option in feature['values']: + if feature['values'][option] == data['value']: + value = int(option) + break + if value is None: + raise Exception(f"Unable to configure appliance. The value {data['value']} must be an integer or a string . Allowed values are {feature['values']}.") + else: + data['value'] = value if 'min' in feature: min = int(feature['min']) @@ -134,7 +196,7 @@ def test_feature(self, data): 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 + return data def recv(self): try: @@ -161,6 +223,7 @@ def reply(self, msg, reply): 'action': 'RESPONSE', 'data': [reply], }) + # send a message to the device def get(self, resource, version=1, action="GET", data=None): @@ -174,9 +237,18 @@ def get(self, resource, version=1, action="GET", data=None): if data is not None: if action == "POST": - if self.test_feature(data) != True: - return - msg["data"] = [data] + #if self.test_feature(data) != True: + # return + #msg["data"] = [data] +# print("REMINDER WIEDER test_and_reformat_feature AKTIVIEREN") +# msg["data"] = [data] + if resource == "/ro/activeProgram": + msg["data"] = [self.test_and_reformat_program(data)] + elif resource == "/ro/values": + msg["data"] = [self.test_and_reformat_feature(data)] + else: + print("Warning: for this resource no checks are performed on data") + msg["data"] = [data] else: msg["data"] = [data] @@ -186,6 +258,11 @@ def get(self, resource, version=1, action="GET", data=None): print(self.name, "Failed to send", e, msg, traceback.format_exc()) self.tx_msg_id += 1 + # same like get, but with POST as action default + def post(self, resource, version=1, action="POST", data=None): + self.get(resource, version, action, data) + + def handle_message(self, buf): msg = json.loads(buf) if self.debug: @@ -219,23 +296,29 @@ def handle_message(self, buf): # ask the device which services it supports self.get("/ci/services") - # the clothes washer wants this, the token doesn't matter, - # although they do not handle padding characters - # they send a response, not sure how to interpet it - token = base64url_encode(get_random_bytes(32)).decode('UTF-8') - token = re.sub(r'=', '', token) - self.get("/ci/authentication", version=2, data={"nonce": token}) - - self.get("/ci/info", version=2) # clothes washer - self.get("/iz/info") # dish washer + if (self.description["type"] != "Dishwasher"): #TODO instead of != dishwasher change it to == clothwasehr - but to the name what it is actually(don't have one)) + # the clothes washer wants this, the token doesn't matter, + # although they do not handle padding characters + # they send a response, not sure how to interpet it + token = base64url_encode(get_random_bytes(32)).decode('UTF-8') + token = re.sub(r'=', '', token) + self.get("/ci/authentication", version=2, data={"nonce": token}) + + if (self.description["type"] != "Dishwasher"): #TODO instead of != dishwasher change it to == clothwasehr - but to what it actually belongs + self.get("/ci/info", version=2) # clothes washer + if (self.description["type"] == "Dishwasher"): + self.get("/iz/info") # dish washer #self.get("/ci/tzInfo", version=2) - self.get("/ni/info") + if (self.description["type"] != "Dishwasher"): #TODO instead of != dishwasher change it to == clothwasehr - but to what it actually belongs + self.get("/ni/info") #self.get("/ni/config", data={"interfaceID": 0}) self.get("/ei/deviceReady", version=2, action="NOTIFY") - self.get("/ro/allDescriptionChanges") + #Note: allDescriptionChanges was twice. One commented out, since not necessary at least for dishwasher. Is it necessary for other devices? + #self.get("/ro/allDescriptionChanges") self.get("/ro/allDescriptionChanges") self.get("/ro/allMandatoryValues") #self.get("/ro/values") + #self.get("/ro/values") else: print(now(), self.name, "Unknown resource", resource, file=sys.stderr) @@ -258,7 +341,12 @@ def handle_message(self, buf): if 'data' in msg: values = self.parse_values(msg["data"]) else: - print(now(), self.name, f"received {msg}") + print(now(), self.name, f"received {action}: {msg}") + + if '517' in values:#uid for BSH.Common.Status.RemoteControlStartAllowed (at least for dishwasher) + global remoteControlStartAllowed + remoteControlStartAllowed = values['517'] + elif resource == "/ci/registeredDevices": # we don't care pass diff --git a/README.md b/README.md index 9cc4bb8..ab2b7a8 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,83 @@ hc2mqtt config.json ``` This tool will establish websockets to the local devices and -transform their messages into MQTT JSON messages. The exact -format is likely to change; it is currently a thin translation -layer over the XML retrieved from cloud servers during the -initial configuration. +transform their messages into MQTT JSON messages. +You have to manually specify the MQTT settings "MQTT_CONF" in your generated config file (explanations see below). +For renaming of the features of your device you also manually have to add a json entry "hc2mqtt" within the device: + +``` +[ + { + "name": "MQTT_CONF", + "host": "localhost", + "topic_prefix": "hc2mqtt/", + "port": 1883, + "username": "", + "password": "" + }, + { + "name":yourDishwasherName + "host":... + "key":... + "description": { + "type": "Dishwasher", + ... + }, + "hc2mqtt": { + "publish_as_json": true, + "publish_as_mqtt": true, + "publish_homie_topics": true, + "rename": { + "default": "short", + "Dishcare.Dishwasher.Setting.ExtraDry": "ExtraDrySet" + }, + "publish": { + "contains": ["BSH.Common.Event.","BSH.Common.Option.","BSH.Common.Root.","BSH.Common.Setting.","BSH.Common.Status.","Dishcare.Dishwasher.Event.","Dishcare.Dishwasher.Option.","Dishcare.Dishwasher.Status.","Dishcare.Dishwasher.Setting."], + "long_names": [] + }, + "publish_never": { + "contains": [], + "long_names": [] + } + }, + "features": { + ... + } +] +``` +First add the MQTT_CONF block (just copy paste and adapt). +If you want to use the [Homie MQTT Convention](https://homieiot.github.io/specification/) +for MQTT publishing and your e.g. smart home system can automatically detect Homie-devices, +you probably have to change ```"topic_prefix": "hc2mqtt/"``` to ```"topic_prefix": "homie/"``` for automatic detection. + +Second, add the ```"hc2mqtt": {...}``` block within each of your devices. This block specifies which features are specified and how they are specified: +- ```"publish_as_json":``` This is a thin translation layer over the XML retrieved from cloud servers during the initial configuration. It publishs one +json object containing all features in tshe MQTT topic //state (Use this for backwards compatibility. Otherwise you probably want one or both of the other options) +- ```"publish_as_mqtt":``` This will publish the features as multiple MQTT topics, so that each feature has its own topic +- ```"publish_homie_topics":``` Specify, if additional topics shall be published according to the Homie MQTT Convention (only applicable with "publish_as_mqtt_topics" set to True) + +Which features are actually published, are specified with ```"publish"``` and ```"publish_never"```. The following rule priority is defined: +- feature name is exactly specified in long_names (individual names of features) in "publish" => publish +- if not: long_names specified in "publish_never" => do not publish +- if not: "publish_never" contains a substring of the feature name => publish not +- if not: "publish" contains a substring of the feature name => publish +- if not: => publish not. + +In ```"rename"``` you can define the default naming behaviour for MQTT exposing (```"short"``` ( = last part of the name), ```"long"``` or ```"uid"```) or specify explicit renaming for some features (overwrites default setting). + +Some Examples: +1) With ```"publish_as_json": true, "publish_as_mqtt": false``` the above example will only publish ``` '{"ProgramFinished": "Off", "DoorState": "Closed", "WaterForecast": 45, "ExtraDrySet": false, [...]}' to 'hc2mqtt/yourDishwasherName/state'``` +2) With ```"publish_as_json": false, "publish_as_mqtt": true``` the above example will only publish ```'Off' to 'hc2mqtt/yourDishwasherName/ProgramFinished/value'``` and ```'Closed' to 'hc2mqtt/yourDishwasherName/DoorState/value'``` and ```'45' to 'hc2mqtt/yourDishwasherName/WaterForecast/value'``` and ```'False' to 'hc2mqtt/yourDishwasherName/ExtraDrySet/value'``` and so on for all features. +3) With ```"publish_as_json": true, "publish_as_mqtt": true``` the above example will publish both from example 1) and 2) +4) With additionally ```"publish_homie_topics": true``` the above example will also the "$-meta-data-topics" for homie like + ``` + 'BSH.Common.Status.DoorState' to 'homie/yourDishwasherName/DoorState/$name' + 'type' to 'hc2mqtt/yourDishwasherName/DoorState/$type' + 'value' to 'hc2mqtt/yourDishwasherName/DoorState/$properties' + 'DoorState' to 'hc2mqtt/yourDishwasherName/DoorState/value/$name' + 'enum' to 'hc2mqtt/yourDishwasherName/DoorState/value/$datatype' + 'Open,Closed' to 'hc2mqtt/yourDishwasherName/DoorState/value/$format' + ``` ### Dishwasher diff --git a/hc2mqtt b/hc2mqtt index f8ba704..5046c49 100755 --- a/hc2mqtt +++ b/hc2mqtt @@ -4,6 +4,8 @@ import json import sys import time +import re +import traceback from threading import Thread import click @@ -12,26 +14,37 @@ import paho.mqtt.client as mqtt from HCDevice import HCDevice from HCSocket import HCSocket, now +debugVerbose = False @click.command() @click.argument("config_file") -@click.option("-h", "--mqtt_host", default="localhost") -@click.option("-p", "--mqtt_prefix", default="homeconnect/") -@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=}") - +@click.option("--onoff_bool", is_flag=True) +@click.option("--log_debug", is_flag=True) +def hc2mqtt(config_file: str, mqtt_ssl: bool, mqtt_cafile: str, mqtt_certfile: str, mqtt_keyfile: str, + onoff_bool: bool, log_debug: bool): + click.echo(f"Hello {config_file} {mqtt_ssl} {mqtt_cafile} {mqtt_certfile} {mqtt_keyfile} {onoff_bool} {log_debug}") + global debugVerbose + debugVerbose = log_debug + with open(config_file, "r") as f: devices = json.load(f) + mqtt_conf = devices[0] + if mqtt_conf["name"] != "MQTT_CONF": + print("ERROR", "The first entry in conf file has to be the MQTT configuration settings. May be manually added. See documentation.") + else: + mqtt_host = mqtt_conf["host"] + mqtt_port = mqtt_conf["port"] + mqtt_prefix = mqtt_conf["topic_prefix"] + mqtt_username = mqtt_conf["username"] + mqtt_password = mqtt_conf["password"] + + if debugVerbose: print(f"Recieved MQTT configuration: {mqtt_host} {mqtt_port} {mqtt_prefix} {'SET' if mqtt_username else 'EMPTY'} {'SET' if mqtt_password else 'EMPTY'} ") + client = mqtt.Client() if mqtt_username and mqtt_password: @@ -46,30 +59,70 @@ def hc2mqtt(config_file: str, mqtt_host: str, mqtt_prefix: str, mqtt_port: int, client.connect(host=mqtt_host, port=mqtt_port, keepalive=70) for device in devices: + print(device["name"]) + if device["name"] == "MQTT_CONF": + #This is the first entry in conf file to configure MQTT client settings. Not a device. Skip. + continue mqtt_topic = mqtt_prefix + device["name"] print(now(), f"topic: {mqtt_topic}") - thread = Thread(target=client_connect, args=(client, device, mqtt_topic)) + thread = Thread(target=client_connect, args=(client, device, mqtt_topic, onoff_bool)) thread.start() client.loop_forever() + + +def check_feature_for_MQTT(feature_name, hc2mqtt_conf): + #whether to expose this feature as node + publish_conf = hc2mqtt_conf["publish"] + nopublish_conf = hc2mqtt_conf["publish_never"] + if (feature_name in publish_conf["long_names"]): + return True + elif (feature_name in nopublish_conf["long_names"]): + return False + + for name_part in nopublish_conf["contains"]: + if name_part in feature_name: + return False + + for name_part in publish_conf["contains"]: + if name_part in feature_name: + return True + + return False + +def getNodeName(uid, jsonfeatures, hc2mqtt_conf): + #gets the node name for the corresping feature + feature = jsonfeatures[uid] + feature_name = feature["name"] + rename_conf = hc2mqtt_conf["rename"] + if feature_name in rename_conf: + return rename_conf[feature_name] + elif rename_conf["default"] == "short": + # trim everything off the name except the last part + node_name = re.sub(r'^.*\.', '', feature_name) + return node_name + elif rename_conf["default"] == "long": + return feature_name + elif rename_conf["default"] == "uid": + return uid + else: + value = rename_conf["default"] + raise Exception(f"rename_conf[\"default\"] == {value} is not known") + +def is_feature_settable(feature): + return (("access" in feature and "rite" in feature["access"]) #look case insensitive for "write" + or ("Program" in feature["name"]) + or ("Dishcare.Dishwasher.Option" in feature["name"]) + or ("Dishcare.Dishwasher.Setting" in feature["name"]) + or ("BSH.Common.Root.ActiveProgram" == feature["name"]) + ) + -# 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", -} - -def client_connect(client, device, mqtt_topic): +def client_connect(client, device, mqtt_topic, onoff_bool): def on_message(client, userdata, msg): global dev mqtt_state = msg.payload.decode() - print(now(),f"received mqtt message {mqtt_state}") + if debugVerbose: print(now(),f"received mqtt message {mqtt_state}") try: msg = json.loads(mqtt_state) if 'uid' in msg: @@ -78,65 +131,195 @@ def client_connect(client, device, mqtt_topic): raise Exception(f"Payload {msg} is not correctly formatted") except Exception as e: print("ERROR", e, file=sys.stderr) + + def on_specific_message(client, userdata, msg): + global dev + mqtt_state = msg.payload.decode() + topic = msg.topic + if debugVerbose: print(f"debug topic: {topic}") + + #extract feature name from mqttTopic/feature_name/value/set + prefixLength = len(mqtt_topic) + 1 # +1 cause of backslash + suffixLength = -10 # len("/value/set") + node_name = topic[prefixLength:suffixLength] + if debugVerbose: print(now(),f"received mqtt specific message '{mqtt_state}' on topic '{topic}' extracted node_name '{node_name}'") + + try: + msg = {} + if (features[node_name][0] == "BSH.Common.Root.ActiveProgram"): + print(programs) + print(programs[mqtt_state]) + msg["program"] = programs[mqtt_state][1] + #Maybe* TODO possible options according to this json format (thanks to @chris-mc1): [{"program":8196, "options":[{"uid":558,"value":0},{"uid":5123,"value":false},{"uid":5126,"value":false},{"uid":5127,"value":false}]}] + #*Not easy to implement together with Homie-MQTT protocol (only one value per MQTT topic). Therefore just set the options before starting the porgram. + dev.post("/ro/activeProgram",data=msg) + else: + #default settable value + msg["uid"] = int(features[node_name][1]) + msg["value"] = mqtt_state + dev.post("/ro/values",data=msg) + if debugVerbose: print(f"debug msg: {msg}") + except Exception as e: + print("ERROR on specific message", e, file=sys.stderr) + + def publish_single(node_name, value): + if (node_name in state): + if (state[node_name] != value): + #to reduce MQTT network traffic spamming only if it is a new value + publish_general("/" + node_name + "/value", value) + else: + if debugVerbose: print(f"node_name {node_name} not in state - not published") + + def publish_general(topic_sub_name, value): + channel = mqtt_topic + topic_sub_name + if debugVerbose: print(now(), device["name"], f"publish '{value}' to '{channel}'") + if isinstance(value, (str, bytearray, int, float)): + client.publish(channel,value,qos=1,retain=True) + else: + print("WARN", "hc2mqtt publish_general(...) value is no instance of (str, bytearray, int, float). Not published.") + + + def expose_node_to_homie(feature, node_name): + #to expose this feature as node + subtopic_node = "/" + node_name + publish_general(subtopic_node+"/$name", feature["name"]) + publish_general(subtopic_node+"/$type", "type") + publish_general(subtopic_node+"/$properties", "value")#generic property for each node + + subtopic_property = subtopic_node + "/" + "value" + publish_general(subtopic_property + "/$name", node_name) + if is_feature_settable(feature): + publish_general(subtopic_property + "/$settable", "true") + #register listener at mqtt server + mqtt_set_topic = mqtt_topic + "/" + node_name + "/value/set" + if debugVerbose: print(now(), device["name"], f"registered mqtt_set_topic on '{mqtt_set_topic}'") + client.subscribe(mqtt_set_topic,1) + client.message_callback_add(mqtt_set_topic, on_specific_message) #register for set topics directly for a specific setting + + #identify and set datatype + enums = [] + if "values" in feature: + options = feature["values"] + for id in options: + enums.append(options[id]) + elif "BSH.Common.Root.ActiveProgram" == feature["name"]: + global programs + programs = {} + for uid in jsonfeatures: + feature_name = jsonfeatures[uid]["name"] + if ".Program." in feature_name: #working for dishwasher. May has to adapted for other devices + short_program_name = getNodeName(uid, jsonfeatures, hc2mqtt_conf) + programs[short_program_name] = (feature_name, uid) + enums.append(short_program_name) + else: + #default + publish_general(subtopic_property + "/$datatype", "string") + + if len(enums) != 0: + publish_general(subtopic_property + "/$datatype", "enum") + enums = ','.join(enums) + publish_general(subtopic_property + "/$format", enums) global dev host = device["host"] + jsonfeatures = device.get("features", None) + hc2mqtt_conf = device["hc2mqtt"] + publish_homie_topics = hc2mqtt_conf["publish_homie_topics"] + publish_as_json = hc2mqtt_conf["publish_as_json"] + publish_as_mqtt = hc2mqtt_conf["publish_as_mqtt"] + if (publish_homie_topics): + #expose Homie MQTT + publish_general("/$homie", "4.0.0") + publish_general("/$name", device["name"]) + publish_general("/$state", "init") + client.will_set(mqtt_topic + "/$state", "lost", 1, True) + + nodes = "" state = {} - for topic in topics: - state[topics[topic]] = None + features = {} #some definitions: feature = the json object of one feature; feature_name = Bosch.Setting.XXX; node_name = XXX + if debugVerbose: print(now(), device["name"], f"hc2mqtt_conf: '{hc2mqtt_conf}'") + for uid in jsonfeatures: + feature = jsonfeatures[uid] + feature_name = feature["name"] + if check_feature_for_MQTT(feature_name, hc2mqtt_conf): + node_name = getNodeName(uid, jsonfeatures, hc2mqtt_conf) + features[node_name] = (feature_name, uid) + nodes = nodes + node_name + "," + if (publish_homie_topics): expose_node_to_homie(feature, node_name) + state[node_name] = None + + nodes = nodes[:-1] #remove last comma + if (publish_homie_topics): publish_general("/$nodes", nodes) - 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 + if publish_as_json: + mqtt_set_topic = mqtt_topic + "/state/set" + client.subscribe(mqtt_set_topic,1) + client.message_callback_add(mqtt_set_topic, on_specific_message) #register for set topics directly for a specific setting + client.on_message = on_message #fallback mqtt callback used for json payload while True: try: ws = HCSocket(host, device["key"], device.get("iv",None)) - dev = HCDevice(ws, device.get("features", None), device["name"]) + dev = HCDevice(ws, jsonfeatures, device["name"], device["description"]) #ws.debug = True ws.reconnect() + if (publish_homie_topics): publish_general("/$state", "ready") while True: msg = dev.recv() if msg is None: + if debugVerbose: print(now(), device["name"], f"hc2mqtt while True msg = None") break if len(msg) > 0: - print(now(), device["name"], msg) - + if debugVerbose: print(now(), device["name"], f"hc2mqtt while True msg = {msg}") + if 'error' in msg: + #this is an error message due to http 404 or similar connection errors, not state errors + if debugVerbose: print(now(), device["name"], f"hc2mqtt while True ERROR msg = {msg}") + continue #todo test that error states are not cut away + update = False - for topic in topics: - value = msg.get(topic, None) + for uid in jsonfeatures: + value = msg.get(uid, 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 + + # Convert "On" to True, "Off" to False if specified in options + if onoff_bool: + if value == "On": + value = True + elif value == "Off": + value = False + + node_name = getNodeName(uid, jsonfeatures, hc2mqtt_conf) + if debugVerbose: print(now(), device["name"], f"hc2mqtt uid for '{node_name}' in msg = {msg[uid]}") + if publish_as_mqtt: publish_single(node_name, value) + state[node_name] = value + if node_name == "remaining": value = "%d:%02d" % (value / 60 / 60, (value / 60) % 60) - - state[new_topic] = value + if publish_as_mqtt: publish_single("remainingseconds" ,value) + state["remainingseconds"] = value + update = True if not update: continue msg = json.dumps(state) - print(now(), device["name"], f"publish to {mqtt_topic} with {msg}") - client.publish(mqtt_topic + "/state", msg) + if publish_as_json: + publish_general("/state", msg) # not needed for Homie - backwards compatiblity of this script + except Exception as e: - print("ERROR", host, e, file=sys.stderr) + print("ERROR", host, str(e) + "\n"+ traceback.format_exc(), file=sys.stderr) +# publish_general("/$state", "lost") time.sleep(5) - + + #TODO: if program exit (does it reach here when aborted?) + if (publish_homie_topics): publish_general("/$state", "lost") + print(now(), f"Thread for {device['name']} exits.") + if __name__ == "__main__": hc2mqtt()