From 30e12f54ba4c3832612187deb275606e4c358ed6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 19 Mar 2024 18:38:07 +0000 Subject: [PATCH] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- HCDevice.py | 421 +++++++++++++++++++++++++++----------------------- HCSocket.py | 245 +++++++++++++++-------------- HCxml2json.py | 144 ++++++++--------- hc-login | 304 ++++++++++++++++++++---------------- hc2mqtt | 200 +++++++++++++----------- 5 files changed, 705 insertions(+), 609 deletions(-) diff --git a/HCDevice.py b/HCDevice.py index eb2c520..3819ebb 100755 --- a/HCDevice.py +++ b/HCDevice.py @@ -39,250 +39,279 @@ # # /iz/services -import sys import json import re -import time -import io +import sys import traceback -from datetime import datetime from base64 import urlsafe_b64encode as base64url_encode +from datetime import datetime + from Crypto.Random import get_random_bytes def now(): - return datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") + return datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") + class HCDevice: - def __init__(self, ws, features, name): - self.ws = ws - self.features = features - self.session_id = None - self.tx_msg_id = None - self.device_name = "hcpy" - self.device_id = "0badcafe" - self.debug = False - self.name = name + def __init__(self, ws, features, name): + self.ws = ws + self.features = features + self.session_id = None + self.tx_msg_id = None + 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 + def parse_values(self, values): + if not self.features: + return values - result = {} + result = {} - for msg in values: - uid = str(msg["uid"]) - value = msg["value"] - value_str = str(value) + for msg in values: + uid = str(msg["uid"]) + value = msg["value"] + value_str = str(value) - name = uid - status = None + name = uid + status = None - if uid in self.features: - status = self.features[uid] + if uid in self.features: + status = self.features[uid] - if status: - name = status["name"] - if "values" in status \ - and value_str in status["values"]: - value = status["values"][value_str] + if status: + 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 + # trim everything off the name except the last part + name = re.sub(r"^.*\.", "", name) + result[name] = value - return result + 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.") + # 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) is False: - raise Exception("{self.name}. Unable to configure appliance. UID must be an integer.") + 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: - raise Exception("{self.name}. Unable to configure appliance. Value is required.") + 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.") + # 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] + 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.") + # 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']}.") + 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) 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']: - raise Exception(f"{self.name}. Unable to configure appliance. Value {data['value']} is not a valid value. Allowed values are {feature['values']}.") + # check if selected list with values is allowed + if "values" in feature: + 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"]: + 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['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}.") + if "min" in feature: + min = int(feature["min"]) + max = int(feature["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 + return True - def recv(self): - try: - buf = self.ws.recv() - if buf is None: - return None - except Exception as e: - print(self.name, "receive error", e, traceback.format_exc()) - return None + def recv(self): + try: + buf = self.ws.recv() + if buf is None: + return None + except Exception as e: + print(self.name, "receive error", e, traceback.format_exc()) + return None - try: - return self.handle_message(buf) - except Exception as e: - print(self.name, "error handling msg", e, buf, traceback.format_exc()) - return None + try: + return self.handle_message(buf) + except Exception as e: + print(self.name, "error handling msg", e, buf, traceback.format_exc()) + return None - # reply to a POST or GET message with new data - def reply(self, msg, reply): - self.ws.send({ - 'sID': msg["sID"], - 'msgID': msg["msgID"], # same one they sent to us - 'resource': msg["resource"], - 'version': msg["version"], - 'action': 'RESPONSE', - 'data': [reply], - }) + # reply to a POST or GET message with new data + def reply(self, msg, reply): + self.ws.send( + { + "sID": msg["sID"], + "msgID": msg["msgID"], # same one they sent to us + "resource": msg["resource"], + "version": msg["version"], + "action": "RESPONSE", + "data": [reply], + } + ) - # send a message to the device - def get(self, resource, version=1, action="GET", data=None): - msg = { - "sID": self.session_id, - "msgID": self.tx_msg_id, - "resource": resource, - "version": version, - "action": action, - } + # send a message to the device + def get(self, resource, version=1, action="GET", data=None): + msg = { + "sID": self.session_id, + "msgID": self.tx_msg_id, + "resource": resource, + "version": version, + "action": action, + } - if data is not None: - if action == "POST": - if self.test_feature(data) is False: - return - msg["data"] = [data] - else: - msg["data"] = [data] + if data is not None: + if action == "POST": + if self.test_feature(data) is False: + return + msg["data"] = [data] + else: + msg["data"] = [data] - 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 + 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(), self.name, "RX:", msg) - sys.stdout.flush() + def handle_message(self, buf): + msg = json.loads(buf) + if self.debug: + print(now(), self.name, "RX:", msg) + sys.stdout.flush() - resource = msg["resource"] - action = msg["action"] + resource = msg["resource"] + action = msg["action"] - values = {} + values = {} - if "code" in msg: - print(now(), self.name, "ERROR", msg["code"]) - values = { - "error": msg["code"], - "resource": msg.get("resource", ''), - } - elif action == "POST": - if resource == "/ei/initialValues": - # this is the first message they send to us and - # establishes our session plus message ids - self.session_id = msg["sID"] - self.tx_msg_id = msg["data"][0]["edMsgID"] + if "code" in msg: + print(now(), self.name, "ERROR", msg["code"]) + values = { + "error": msg["code"], + "resource": msg.get("resource", ""), + } + elif action == "POST": + if resource == "/ei/initialValues": + # this is the first message they send to us and + # establishes our session plus message ids + self.session_id = msg["sID"] + self.tx_msg_id = msg["data"][0]["edMsgID"] - self.reply(msg, { - "deviceType": "Application", - "deviceName": self.device_name, - "deviceID": self.device_id, - }) + self.reply( + msg, + { + "deviceType": "Application", + "deviceName": self.device_name, + "deviceID": self.device_id, + }, + ) - # ask the device which services it supports - self.get("/ci/services") + # 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}) + # 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 - #self.get("/ci/tzInfo", version=2) - self.get("/ni/info") - #self.get("/ni/config", data={"interfaceID": 0}) - self.get("/ei/deviceReady", version=2, action="NOTIFY") - self.get("/ro/allDescriptionChanges") - self.get("/ro/allDescriptionChanges") - self.get("/ro/allMandatoryValues") - #self.get("/ro/values") - else: - print(now(), self.name, "Unknown resource", resource, file=sys.stderr) + self.get("/ci/info", version=2) # clothes washer + self.get("/iz/info") # dish washer + # self.get("/ci/tzInfo", version=2) + self.get("/ni/info") + # self.get("/ni/config", data={"interfaceID": 0}) + self.get("/ei/deviceReady", version=2, action="NOTIFY") + self.get("/ro/allDescriptionChanges") + self.get("/ro/allDescriptionChanges") + self.get("/ro/allMandatoryValues") + # self.get("/ro/values") + else: + print(now(), self.name, "Unknown resource", resource, file=sys.stderr) - elif action == "RESPONSE" or action == "NOTIFY": - if resource == "/iz/info" or resource == "/ci/info": - # we could validate that this matches our machine - pass + elif action == "RESPONSE" or action == "NOTIFY": + if resource == "/iz/info" or resource == "/ci/info": + # we could validate that this matches our machine + pass - elif resource == "/ro/descriptionChange" \ - or resource == "/ro/allDescriptionChanges": - # we asked for these but don't know have to parse yet - pass + elif ( + resource == "/ro/descriptionChange" + or resource == "/ro/allDescriptionChanges" + ): + # we asked for these but don't know have to parse yet + pass - elif resource == "/ni/info": - # we're already talking, so maybe we don't care? - pass + elif resource == "/ni/info": + # we're already talking, so maybe we don't care? + pass - elif resource == "/ro/allMandatoryValues" \ - or resource == "/ro/values": - 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 + elif resource == "/ro/allMandatoryValues" or resource == "/ro/values": + 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 - elif resource == "/ci/services": - self.services = {} - for service in msg["data"]: - self.services[service["service"]] = { - "version": service["version"], - } - #print(self.name, now(), "services", self.services) + elif resource == "/ci/services": + self.services = {} + for service in msg["data"]: + self.services[service["service"]] = { + "version": service["version"], + } + # print(self.name, now(), "services", self.services) - # we should figure out which ones to query now -# if "iz" in self.services: -# self.get("/iz/info", version=self.services["iz"]["version"]) -# if "ni" in self.services: -# self.get("/ni/info", version=self.services["ni"]["version"]) -# if "ei" in self.services: -# self.get("/ei/deviceReady", version=self.services["ei"]["version"], action="NOTIFY") + # we should figure out which ones to query now + # if "iz" in self.services: + # self.get("/iz/info", version=self.services["iz"]["version"]) + # if "ni" in self.services: + # self.get("/ni/info", version=self.services["ni"]["version"]) + # if "ei" in self.services: + # self.get("/ei/deviceReady", version=self.services["ei"]["version"], action="NOTIFY") - #self.get("/if/info") + # self.get("/if/info") - else: - print(now(), self.name, "Unknown", msg) + else: + print(now(), self.name, "Unknown", msg) - # return whatever we've parsed out of it - return values + # return whatever we've parsed out of it + return values diff --git a/HCSocket.py b/HCSocket.py index 5b4fb3b..83e4b69 100755 --- a/HCSocket.py +++ b/HCSocket.py @@ -1,27 +1,29 @@ # Create a websocket that wraps a connection to a # Bosh-Siemens Home Connect device -import socket -import ssl -import sslpsk -import websocket -import sys import json import re -import time -import io +import socket +import ssl +import sys from base64 import urlsafe_b64decode as base64url from datetime import datetime + +import sslpsk +import websocket from Crypto.Cipher import AES from Crypto.Hash import HMAC, SHA256 from Crypto.Random import get_random_bytes + # Convience to compute an HMAC on a message -def hmac(key,msg): - mac = HMAC.new(key, msg=msg, digestmod=SHA256).digest() - return mac +def hmac(key, msg): + mac = HMAC.new(key, msg=msg, digestmod=SHA256).digest() + return mac + def now(): - return datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") + return datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") + # Monkey patch for sslpsk in pip using the old _sslobj def _sslobj(sock): @@ -29,142 +31,145 @@ def _sslobj(sock): return sock._sslobj._sslobj else: return sock._sslobj + + sslpsk.sslpsk._sslobj = _sslobj class HCSocket: - def __init__(self, host, psk64, iv64=None): - self.host = host - self.psk = base64url(psk64 + '===') - self.debug = False + def __init__(self, host, psk64, iv64=None): + self.host = host + self.psk = base64url(psk64 + "===") + self.debug = False - if iv64: - # an HTTP self-encrypted socket - self.http = True - self.iv = base64url(iv64 + '===') - self.enckey = hmac(self.psk, b'ENC') - self.mackey = hmac(self.psk, b'MAC') - self.port = 80 - self.uri = "ws://" + host + ":80/homeconnect" - else: - self.http = False - self.port = 443 - self.uri = "wss://" + host + ":443/homeconnect" + if iv64: + # an HTTP self-encrypted socket + self.http = True + self.iv = base64url(iv64 + "===") + self.enckey = hmac(self.psk, b"ENC") + self.mackey = hmac(self.psk, b"MAC") + self.port = 80 + self.uri = "ws://" + host + ":80/homeconnect" + else: + self.http = False + self.port = 443 + self.uri = "wss://" + host + ":443/homeconnect" - # don't connect automatically so that debug etc can be set - #self.reconnect() + # don't connect automatically so that debug etc can be set + # self.reconnect() - # restore the encryption state for a fresh connection - # this is only used by the HTTP connection - def reset(self): - if not self.http: - return - self.last_rx_hmac = bytes(16) - self.last_tx_hmac = bytes(16) + # restore the encryption state for a fresh connection + # this is only used by the HTTP connection + def reset(self): + if not self.http: + return + self.last_rx_hmac = bytes(16) + self.last_tx_hmac = bytes(16) - self.aes_encrypt = AES.new(self.enckey, AES.MODE_CBC, self.iv) - self.aes_decrypt = AES.new(self.enckey, AES.MODE_CBC, self.iv) + self.aes_encrypt = AES.new(self.enckey, AES.MODE_CBC, self.iv) + self.aes_decrypt = AES.new(self.enckey, AES.MODE_CBC, self.iv) - # hmac an inbound or outbound message, chaining the last hmac too - def hmac_msg(self, direction, enc_msg): - hmac_msg = self.iv + direction + enc_msg - return hmac(self.mackey, hmac_msg)[0:16] + # hmac an inbound or outbound message, chaining the last hmac too + def hmac_msg(self, direction, enc_msg): + hmac_msg = self.iv + direction + enc_msg + return hmac(self.mackey, hmac_msg)[0:16] - def decrypt(self,buf): - if len(buf) < 32: - print("Short message?", buf.hex(), file=sys.stderr) - return None - if len(buf) % 16 != 0: - print("Unaligned message? probably bad", buf.hex(), file=sys.stderr) + def decrypt(self, buf): + if len(buf) < 32: + print("Short message?", buf.hex(), file=sys.stderr) + return None + if len(buf) % 16 != 0: + print("Unaligned message? probably bad", buf.hex(), file=sys.stderr) - # split the message into the encrypted message and the first 16-bytes of the HMAC - enc_msg = buf[0:-16] - their_hmac = buf[-16:] + # split the message into the encrypted message and the first 16-bytes of the HMAC + enc_msg = buf[0:-16] + their_hmac = buf[-16:] - # compute the expected hmac on the encrypted message - our_hmac = self.hmac_msg(b'\x43' + self.last_rx_hmac, enc_msg) + # compute the expected hmac on the encrypted message + our_hmac = self.hmac_msg(b"\x43" + self.last_rx_hmac, enc_msg) - if their_hmac != our_hmac: - print("HMAC failure", their_hmac.hex(), our_hmac.hex(), file=sys.stderr) - return None + if their_hmac != our_hmac: + print("HMAC failure", their_hmac.hex(), our_hmac.hex(), file=sys.stderr) + return None - self.last_rx_hmac = their_hmac + self.last_rx_hmac = their_hmac - # decrypt the message with CBC, so the last message block is mixed in - msg = self.aes_decrypt.decrypt(enc_msg) + # decrypt the message with CBC, so the last message block is mixed in + msg = self.aes_decrypt.decrypt(enc_msg) - # check for padding and trim it off the end - pad_len = msg[-1] - if len(msg) < pad_len: - print("padding error?", msg.hex()) - return None + # check for padding and trim it off the end + pad_len = msg[-1] + if len(msg) < pad_len: + print("padding error?", msg.hex()) + return None - return msg[0:-pad_len] + return msg[0:-pad_len] - def encrypt(self, clear_msg): - # convert the UTF-8 string into a byte array - clear_msg = bytes(clear_msg, 'utf-8') + def encrypt(self, clear_msg): + # convert the UTF-8 string into a byte array + clear_msg = bytes(clear_msg, "utf-8") - # pad the buffer, adding an extra block if necessary - pad_len = 16 - (len(clear_msg) % 16) - if pad_len == 1: - pad_len += 16 - pad = b'\x00' + get_random_bytes(pad_len-2) + bytearray([pad_len]) + # pad the buffer, adding an extra block if necessary + pad_len = 16 - (len(clear_msg) % 16) + if pad_len == 1: + pad_len += 16 + pad = b"\x00" + get_random_bytes(pad_len - 2) + bytearray([pad_len]) - clear_msg = clear_msg + pad + clear_msg = clear_msg + pad - # encrypt the padded message with CBC, so there is chained - # state from the last cipher block sent - enc_msg = self.aes_encrypt.encrypt(clear_msg) + # encrypt the padded message with CBC, so there is chained + # state from the last cipher block sent + enc_msg = self.aes_encrypt.encrypt(clear_msg) - # compute the hmac of the encrypted message, chaining the - # hmac of the previous message plus direction 'E' - self.last_tx_hmac = self.hmac_msg(b'\x45' + self.last_tx_hmac, enc_msg) + # compute the hmac of the encrypted message, chaining the + # hmac of the previous message plus direction 'E' + self.last_tx_hmac = self.hmac_msg(b"\x45" + self.last_tx_hmac, enc_msg) - # append the new hmac to the message - return enc_msg + self.last_tx_hmac + # append the new hmac to the message + return enc_msg + self.last_tx_hmac - def reconnect(self): - self.reset() - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.connect((self.host,self.port)) + def reconnect(self): + self.reset() + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((self.host, self.port)) - if not self.http: - sock = sslpsk.wrap_socket( - sock, - ssl_version = ssl.PROTOCOL_TLSv1_2, - ciphers = 'ECDHE-PSK-CHACHA20-POLY1305', - psk = self.psk, - ) + if not self.http: + sock = sslpsk.wrap_socket( + sock, + ssl_version=ssl.PROTOCOL_TLSv1_2, + ciphers="ECDHE-PSK-CHACHA20-POLY1305", + psk=self.psk, + ) - print(now(), "CON:", self.uri) - self.ws = websocket.WebSocket() - self.ws.connect(self.uri, - socket = sock, - origin = "", - ) + print(now(), "CON:", self.uri) + self.ws = websocket.WebSocket() + self.ws.connect( + self.uri, + socket=sock, + origin="", + ) - def send(self, msg): - buf = json.dumps(msg, separators=(',', ':') ) - # swap " for ' - buf = re.sub("'", '"', buf) - if self.debug: - print(now(), "TX:", buf) - if self.http: - self.ws.send_binary(self.encrypt(buf)) - else: - self.ws.send(buf) + def send(self, msg): + buf = json.dumps(msg, separators=(",", ":")) + # swap " for ' + buf = re.sub("'", '"', buf) + if self.debug: + print(now(), "TX:", buf) + if self.http: + self.ws.send_binary(self.encrypt(buf)) + else: + self.ws.send(buf) - def recv(self): - buf = self.ws.recv() - if buf is None or buf == "": - return None + def recv(self): + buf = self.ws.recv() + if buf is None or buf == "": + return None - if self.http: - buf = self.decrypt(buf) - if buf is None: - return None + if self.http: + buf = self.decrypt(buf) + if buf is None: + return None - if self.debug: - print(now(), "RX:", buf) - return buf + if self.debug: + print(now(), "RX:", buf) + return buf diff --git a/HCxml2json.py b/HCxml2json.py index 80fad80..e415d13 100755 --- a/HCxml2json.py +++ b/HCxml2json.py @@ -7,7 +7,6 @@ # import sys -import json import xml.etree.ElementTree as ET ##################### @@ -16,95 +15,96 @@ import xml.etree.ElementTree as ET # list of UIDs # + def parse_xml_list(codes, entries, enums): - for el in entries: - # not sure how to parse refCID and refDID - uid = int(el.attrib["uid"], 16) + for el in entries: + # not sure how to parse refCID and refDID + uid = int(el.attrib["uid"], 16) - if not uid in codes: - print("UID", uid, " not known!", file=sys.stderr) + if not uid in codes: + print("UID", uid, " not known!", file=sys.stderr) - data = codes[uid]; - if "uid" in codes: - print("UID", uid, " used twice?", data, file=sys.stderr) + data = codes[uid] + if "uid" in codes: + print("UID", uid, " used twice?", data, file=sys.stderr) - for key in el.attrib: - data[key] = el.attrib[key] + for key in el.attrib: + data[key] = el.attrib[key] - # clean up later - #del data["uid"] + # clean up later + # del data["uid"] - if "enumerationType" in el.attrib: - del data["enumerationType"] - enum_id = int(el.attrib["enumerationType"], 16) - data["values"] = enums[enum_id]["values"] + if "enumerationType" in el.attrib: + del data["enumerationType"] + enum_id = int(el.attrib["enumerationType"], 16) + data["values"] = enums[enum_id]["values"] + + # codes[uid] = data - #codes[uid] = data def parse_machine_description(entries): - description = {} + description = {} - for el in entries: - prefix, has_namespace, tag = el.tag.partition('}') - if tag != "pairableDeviceTypes": - description[tag] = el.text + for el in entries: + prefix, has_namespace, tag = el.tag.partition("}") + if tag != "pairableDeviceTypes": + description[tag] = el.text - return description + return description -def xml2json(features_xml,description_xml): - # the feature file has features, errors, and enums - # for now the ordering is hardcoded - featuremapping = ET.fromstring(features_xml) #.getroot() - description = ET.fromstring(description_xml) #.getroot() +def xml2json(features_xml, description_xml): + # the feature file has features, errors, and enums + # for now the ordering is hardcoded + featuremapping = ET.fromstring(features_xml) # .getroot() + description = ET.fromstring(description_xml) # .getroot() - ##################### - # - # Parse the feature file - # + ##################### + # + # Parse the feature file + # - features = {} - errors = {} - enums = {} + features = {} + errors = {} + enums = {} - # Features are all possible UIDs - for child in featuremapping[1]: #.iter('feature'): - uid = int(child.attrib["refUID"], 16) - name = child.text - features[uid] = { - "name": name, - } + # Features are all possible UIDs + for child in featuremapping[1]: # .iter('feature'): + uid = int(child.attrib["refUID"], 16) + name = child.text + features[uid] = { + "name": name, + } - # Errors - for child in featuremapping[2]: - uid = int(child.attrib["refEID"], 16) - name = child.text - errors[uid] = name + # Errors + for child in featuremapping[2]: + uid = int(child.attrib["refEID"], 16) + name = child.text + errors[uid] = name - # Enums - for child in featuremapping[3]: - uid = int(child.attrib["refENID"], 16) - enum_name = child.attrib["enumKey"] - values = {} - for v in child: - value = int(v.attrib["refValue"]) - name = v.text - values[value] = name - enums[uid] = { - "name": enum_name, - "values": values, - } + # Enums + for child in featuremapping[3]: + uid = int(child.attrib["refENID"], 16) + enum_name = child.attrib["enumKey"] + values = {} + for v in child: + value = int(v.attrib["refValue"]) + name = v.text + values[value] = name + enums[uid] = { + "name": enum_name, + "values": values, + } + for i in range(4, 8): + parse_xml_list(features, description[i], enums) - for i in range(4,8): - parse_xml_list(features, description[i], enums) + # remove the duplicate uid field + for uid in features: + if "uid" in features[uid]: + del features[uid]["uid"] - # remove the duplicate uid field - for uid in features: - if "uid" in features[uid]: - del features[uid]["uid"] - - return { - "description": parse_machine_description(description[3]), - "features": features, - } + return { + "description": parse_machine_description(description[3]), + "features": features, + } diff --git a/hc-login b/hc-login index 1de95f0..a6710f8 100755 --- a/hc-login +++ b/hc-login @@ -3,39 +3,37 @@ # https://github.com/openid/AppAuth-Android # A really nice walk through of how it works is: # https://auth0.com/docs/get-started/authentication-and-authorization-flow/call-your-api-using-the-authorization-code-flow-with-pkce -import requests -from urllib.parse import urlparse, parse_qs, urlencode, urlunparse -from lxml import html import io +import json import re import sys -import json -from time import time -from base64 import b64decode as base64_decode from base64 import urlsafe_b64encode as base64url_encode -from bs4 import BeautifulSoup -from Crypto.Random import get_random_bytes -from Crypto.Hash import SHA256 +from urllib.parse import parse_qs, urlencode, urlparse from zipfile import ZipFile -from HCxml2json import xml2json -import logging +import requests +from bs4 import BeautifulSoup +from Crypto.Hash import SHA256 +from Crypto.Random import get_random_bytes + +from HCxml2json import xml2json # These two lines enable debugging at httplib level (requests->urllib3->http.client) # You will see the REQUEST, including HEADERS and DATA, and RESPONSE with HEADERS but without DATA. # The only thing missing will be the response.body which is not logged. -import http.client as http_client -#http_client.HTTPConnection.debuglevel = 1 +# http_client.HTTPConnection.debuglevel = 1 # You must initialize logging, otherwise you'll not see debug output. -#logging.basicConfig() -#logging.getLogger().setLevel(logging.DEBUG) -#requests_log = logging.getLogger("requests.packages.urllib3") -#requests_log.setLevel(logging.DEBUG) -#requests_log.propagate = True +# logging.basicConfig() +# logging.getLogger().setLevel(logging.DEBUG) +# requests_log = logging.getLogger("requests.packages.urllib3") +# requests_log.setLevel(logging.DEBUG) +# requests_log.propagate = True + def debug(*args): - print(*args, file=sys.stderr) + print(*args, file=sys.stderr) + email = sys.argv[1] password = sys.argv[2] @@ -45,8 +43,8 @@ headers = {"User-Agent": "hc-login/1.0"} session = requests.Session() session.headers.update(headers) -base_url = 'https://api.home-connect.com/security/oauth/' -asset_url = 'https://prod.reu.rest.homeconnectegw.com/' +base_url = "https://api.home-connect.com/security/oauth/" +asset_url = "https://prod.reu.rest.homeconnectegw.com/" ##############3 # @@ -55,65 +53,82 @@ asset_url = 'https://prod.reu.rest.homeconnectegw.com/' # even after the singlekey detour. # # The app_id and scope are hardcoded in the application -app_id = '9B75AC9EC512F36C84256AC47D813E2C1DD0D6520DF774B020E1E6E2EB29B1F3' -scope = ["ReadAccount","Settings","IdentifyAppliance","Control","DeleteAppliance","WriteAppliance","ReadOrigApi","Monitor","WriteOrigApi","Images"] -scope = ["ReadOrigApi",] +app_id = "9B75AC9EC512F36C84256AC47D813E2C1DD0D6520DF774B020E1E6E2EB29B1F3" +scope = [ + "ReadAccount", + "Settings", + "IdentifyAppliance", + "Control", + "DeleteAppliance", + "WriteAppliance", + "ReadOrigApi", + "Monitor", + "WriteOrigApi", + "Images", +] +scope = [ + "ReadOrigApi", +] + def b64(b): - return re.sub(r'=', '', base64url_encode(b).decode('UTF-8')) + return re.sub(r"=", "", base64url_encode(b).decode("UTF-8")) + + def b64random(num): - return b64(base64url_encode(get_random_bytes(num))) + return b64(base64url_encode(get_random_bytes(num))) + verifier = b64(get_random_bytes(32)) login_query = { - "response_type": "code", - "prompt": "login", - "code_challenge": b64(SHA256.new(verifier.encode('UTF-8')).digest()), - "code_challenge_method": "S256", - "client_id": app_id, - "scope": ' '.join(scope), - "nonce": b64random(16), - "state": b64random(16), - "redirect_uri": 'hcauth://auth/prod', - "redirect_target": 'icore', + "response_type": "code", + "prompt": "login", + "code_challenge": b64(SHA256.new(verifier.encode("UTF-8")).digest()), + "code_challenge_method": "S256", + "client_id": app_id, + "scope": " ".join(scope), + "nonce": b64random(16), + "state": b64random(16), + "redirect_uri": "hcauth://auth/prod", + "redirect_target": "icore", } -loginpage_url = base_url + 'authorize?' + urlencode(login_query) -token_url = base_url + 'token' +loginpage_url = base_url + "authorize?" + urlencode(login_query) +token_url = base_url + "token" debug(f"{loginpage_url=}") r = session.get(loginpage_url) if r.status_code != requests.codes.ok: - print("error fetching login url!", loginpage_url, r.text, file=sys.stderr) - exit(1) + print("error fetching login url!", loginpage_url, r.text, file=sys.stderr) + exit(1) # get the session from the text if not (match := re.search(r'"sessionId" value="(.*?)"', r.text)): - print("Unable to find session id in login page") - exit(1) + print("Unable to find session id in login page") + exit(1) session_id = match[1] if not (match := re.search(r'"sessionData" value="(.*?)"', r.text)): - print("Unable to find session data in login page") - exit(1) + print("Unable to find session data in login page") + exit(1) session_data = match[1] debug("--------") -# now that we have a session id, contact the +# now that we have a session id, contact the # single key host to start the new login flow -singlekey_host = 'https://singlekey-id.com' -login_url = singlekey_host + '/auth/en-us/log-in/' +singlekey_host = "https://singlekey-id.com" +login_url = singlekey_host + "/auth/en-us/log-in/" preauth_url = singlekey_host + "/auth/connect/authorize" preauth_query = { - "client_id": "11F75C04-21C2-4DA9-A623-228B54E9A256", - "redirect_uri": "https://api.home-connect.com/security/oauth/redirect_target", - "response_type": "code", - "scope": "openid email profile offline_access homeconnect.general", - "prompt": "login", - "style_id": "bsh_hc_01", - "state": '{"session_id":"' + session_id + '"}', # important: no spaces! + "client_id": "11F75C04-21C2-4DA9-A623-228B54E9A256", + "redirect_uri": "https://api.home-connect.com/security/oauth/redirect_target", + "response_type": "code", + "scope": "openid email profile offline_access homeconnect.general", + "prompt": "login", + "style_id": "bsh_hc_01", + "state": '{"session_id":"' + session_id + '"}', # important: no spaces! } # fetch the preauth state to get the final callback url @@ -121,18 +136,18 @@ preauth_url += "?" + urlencode(preauth_query) # loop until we have the callback url while True: - debug(f"next {preauth_url=}") - r = session.get(preauth_url, allow_redirects=False) - if r.status_code == 200: - break - if r.status_code > 300 and r.status_code < 400: - preauth_url = r.headers["location"] - # Make relative locations absolute - if not bool(urlparse(preauth_url).netloc): - preauth_url = singlekey_host + preauth_url - continue - print(f"2: {preauth_url=}: failed to fetch {r} {r.text}", file=sys.stderr) - exit(1) + debug(f"next {preauth_url=}") + r = session.get(preauth_url, allow_redirects=False) + if r.status_code == 200: + break + if r.status_code > 300 and r.status_code < 400: + preauth_url = r.headers["location"] + # Make relative locations absolute + if not bool(urlparse(preauth_url).netloc): + preauth_url = singlekey_host + preauth_url + continue + print(f"2: {preauth_url=}: failed to fetch {r} {r.text}", file=sys.stderr) + exit(1) # get the ReturnUrl from the response query = parse_qs(urlparse(preauth_url).query) @@ -140,36 +155,55 @@ return_url = query["ReturnUrl"][0] debug(f"{return_url=}") if "X-CSRF-FORM-TOKEN" in r.cookies: - headers["RequestVerificationToken"] = r.cookies["X-CSRF-FORM-TOKEN"] + headers["RequestVerificationToken"] = r.cookies["X-CSRF-FORM-TOKEN"] session.headers.update(headers) debug("--------") -soup = BeautifulSoup(r.text, 'html.parser') -requestVerificationToken = soup.find('input', {'name': '__RequestVerificationToken'}).get('value') -r = session.post(preauth_url, data={"UserIdentifierInput.EmailInput.StringValue": email, "__RequestVerificationToken": requestVerificationToken }, allow_redirects=False) +soup = BeautifulSoup(r.text, "html.parser") +requestVerificationToken = soup.find( + "input", {"name": "__RequestVerificationToken"} +).get("value") +r = session.post( + preauth_url, + data={ + "UserIdentifierInput.EmailInput.StringValue": email, + "__RequestVerificationToken": requestVerificationToken, + }, + allow_redirects=False, +) -password_url = r.headers['location'] +password_url = r.headers["location"] if not bool(urlparse(password_url).netloc): - password_url = singlekey_host + password_url + password_url = singlekey_host + password_url r = session.get(password_url, allow_redirects=False) -soup = BeautifulSoup(r.text, 'html.parser') -requestVerificationToken = soup.find('input', {'name': '__RequestVerificationToken'}).get('value') +soup = BeautifulSoup(r.text, "html.parser") +requestVerificationToken = soup.find( + "input", {"name": "__RequestVerificationToken"} +).get("value") -r = session.post(password_url, data={"Password": password, "RememberMe": "false", "__RequestVerificationToken": requestVerificationToken }, allow_redirects=False) +r = session.post( + password_url, + data={ + "Password": password, + "RememberMe": "false", + "__RequestVerificationToken": requestVerificationToken, + }, + allow_redirects=False, +) if return_url.startswith("/"): - return_url = singlekey_host + return_url + return_url = singlekey_host + return_url while True: - r = session.get(return_url, allow_redirects=False) - debug(f"{return_url=}, {r} {r.text}") - if r.status_code != 302: - break - return_url = r.headers["location"] - if return_url.startswith("hcauth://"): - break + r = session.get(return_url, allow_redirects=False) + debug(f"{return_url=}, {r} {r.text}") + if r.status_code != 302: + break + return_url = r.headers["location"] + if return_url.startswith("hcauth://"): + break debug(f"{return_url=}") debug("--------") @@ -178,92 +212,92 @@ url = urlparse(return_url) query = parse_qs(url.query) code = query.get("code")[0] state = query.get("state")[0] -grant_type = query.get("grant_type")[0] # "authorization_code" +grant_type = query.get("grant_type")[0] # "authorization_code" debug(f"{code=} {grant_type=} {state=}") -auth_url = base_url + 'login' -token_url = base_url + 'token' +auth_url = base_url + "login" +token_url = base_url + "token" token_fields = { - "grant_type": grant_type, - "client_id": app_id, - "code_verifier": verifier, - "code": code, - "redirect_uri": login_query["redirect_uri"], + "grant_type": grant_type, + "client_id": app_id, + "code_verifier": verifier, + "code": code, + "redirect_uri": login_query["redirect_uri"], } debug(f"{token_url=} {token_fields=}") r = requests.post(token_url, data=token_fields, allow_redirects=False) if r.status_code != requests.codes.ok: - print("Bad code?", file=sys.stderr) - print(r.headers, r.text) - exit(1) + print("Bad code?", file=sys.stderr) + print(r.headers, r.text) + exit(1) -debug('--------- got token page ----------') +debug("--------- got token page ----------") token = json.loads(r.text)["access_token"] debug(f"Received access {token=}") headers = { - "Authorization": "Bearer " + token, + "Authorization": "Bearer " + token, } # now we can fetch the rest of the account info r = requests.get(asset_url + "account/details", headers=headers) if r.status_code != requests.codes.ok: - print("unable to fetch account details", file=sys.stderr) - print(r.headers, r.text) - exit(1) + print("unable to fetch account details", file=sys.stderr) + print(r.headers, r.text) + exit(1) -#print(r.text) +# print(r.text) account = json.loads(r.text) configs = [] print(account, file=sys.stderr) for app in account["data"]["homeAppliances"]: - app_brand = app["brand"] - app_type = app["type"] - app_id = app["identifier"] + app_brand = app["brand"] + app_type = app["type"] + app_id = app["identifier"] - config = { - "name": app_type.lower(), - } + config = { + "name": app_type.lower(), + } - configs.append(config) + configs.append(config) - if "tls" in app: - # fancy machine with TLS support - config["host"] =app_brand + "-" + app_type + "-" + app_id - config["key"] = app["tls"]["key"] - else: - # less fancy machine with HTTP support - config["host"] = app_id - config["key"] = app["aes"]["key"] - config["iv"] = app["aes"]["iv"] + if "tls" in app: + # fancy machine with TLS support + config["host"] = app_brand + "-" + app_type + "-" + app_id + config["key"] = app["tls"]["key"] + else: + # less fancy machine with HTTP support + config["host"] = app_id + config["key"] = app["aes"]["key"] + config["iv"] = app["aes"]["iv"] - # Fetch the XML zip file for this device - app_url = asset_url + "api/iddf/v1/iddf/" + app_id - print("fetching", app_url, file=sys.stderr) - r = requests.get(app_url, headers=headers) - if r.status_code != requests.codes.ok: - print(app_id, ": unable to fetch machine description?") - next + # Fetch the XML zip file for this device + app_url = asset_url + "api/iddf/v1/iddf/" + app_id + print("fetching", app_url, file=sys.stderr) + r = requests.get(app_url, headers=headers) + if r.status_code != requests.codes.ok: + print(app_id, ": unable to fetch machine description?") + next - # we now have a zip file with XML, let's unpack them - content = r.content - print(app_url + ": " + app_id + ".zip", file=sys.stderr) - with open(app_id + ".zip", "wb") as f: - f.write(content) - z = ZipFile(io.BytesIO(content)) - #print(z.infolist()) - features = z.open(app_id + "_FeatureMapping.xml").read() - description = z.open(app_id + "_DeviceDescription.xml").read() + # we now have a zip file with XML, let's unpack them + content = r.content + print(app_url + ": " + app_id + ".zip", file=sys.stderr) + with open(app_id + ".zip", "wb") as f: + f.write(content) + z = ZipFile(io.BytesIO(content)) + # print(z.infolist()) + features = z.open(app_id + "_FeatureMapping.xml").read() + description = z.open(app_id + "_DeviceDescription.xml").read() - machine = xml2json(features, description) - config["description"] = machine["description"] - config["features"] = machine["features"] + machine = xml2json(features, description) + config["description"] = machine["description"] + config["features"] = machine["features"] print(json.dumps(configs, indent=4)) diff --git a/hc2mqtt b/hc2mqtt index 77a209b..ff7e7fd 100755 --- a/hc2mqtt +++ b/hc2mqtt @@ -2,9 +2,9 @@ # Contact Bosh-Siemens Home Connect devices # and connect their messages to the mqtt server import json +import ssl import sys import time -import ssl from threading import Thread import click @@ -14,6 +14,7 @@ import paho.mqtt.client as mqtt from HCDevice import HCDevice from HCSocket import HCSocket, now + @click.command() @click.option("-d", "--devices_file", default="config/devices.json") @click.option("-h", "--mqtt_host", default="localhost") @@ -27,120 +28,147 @@ from HCSocket import HCSocket, now @click.option("--mqtt_keyfile") @click.option("--mqtt_clientname", default="hcpy") @click_config_file.configuration_option() +def hc2mqtt( + devices_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 {devices_file=} {mqtt_host=} {mqtt_prefix=} {mqtt_port=} {mqtt_username=} {mqtt_password=} " + f"{mqtt_ssl=} {mqtt_cafile=} {mqtt_certfile=} {mqtt_keyfile=} {mqtt_clientname=}" + ) -def hc2mqtt(devices_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 {devices_file=} {mqtt_host=} {mqtt_prefix=} {mqtt_port=} {mqtt_username=} {mqtt_password=} " - f"{mqtt_ssl=} {mqtt_cafile=} {mqtt_certfile=} {mqtt_keyfile=} {mqtt_clientname=}") + with open(devices_file, "r") as f: + devices = json.load(f) - with open(devices_file, "r") as f: - devices = json.load(f) + client = mqtt.Client(mqtt_clientname) - client = mqtt.Client(mqtt_clientname) + if mqtt_username and mqtt_password: + client.username_pw_set(mqtt_username, mqtt_password) - 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) - 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) - 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() - 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() - client.loop_forever() # Map their value names to easier state names topics = { - "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) + 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 + 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 + 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 device_topics: - if not topic.isdigit(): #We only want the named topics - state[device_topics[topic]] = None + state = {} + 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 + 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[device["name"]] = HCDevice(ws, device.get("features", None), device["name"]) + while True: + try: + print(now(), device["name"], f"connecting to {host}") + ws = HCSocket(host, device["key"], device.get("iv", None)) + dev[device["name"]] = HCDevice( + ws, device.get("features", None), device["name"] + ) - #ws.debug = True - ws.reconnect() + # ws.debug = True + ws.reconnect() - while True: - msg = dev[device["name"]].recv() - if msg is None: - break - if len(msg) > 0: - print(now(), device["name"], msg) + while True: + msg = dev[device["name"]].recv() + if msg is None: + break + if len(msg) > 0: + print(now(), device["name"], msg) - update = False - for topic in device_topics: - value = msg.get(topic, None) - if value is None: - continue + update = False + for topic in device_topics: + value = msg.get(topic, None) + if value is None: + continue - # 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 + new_topic = device_topics[topic] + state[new_topic] = value + update = True - if not update: - continue + 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) + msg = json.dumps(state) + print(now(), device["name"], f"publish to {mqtt_topic} with {msg}") + client.publish(mqtt_topic + "/state", msg) - except Exception as e: - print("ERROR", host, e, file=sys.stderr) + except Exception as e: + print("ERROR", host, e, file=sys.stderr) + + time.sleep(5) - time.sleep(5) if __name__ == "__main__": - hc2mqtt() + hc2mqtt()