[pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
This commit is contained in:
pre-commit-ci[bot]
2024-03-19 18:38:07 +00:00
parent d8a5e22cb9
commit 30e12f54ba
5 changed files with 705 additions and 609 deletions

View File

@@ -39,250 +39,279 @@
# #
# /iz/services # /iz/services
import sys
import json import json
import re import re
import time import sys
import io
import traceback import traceback
from datetime import datetime
from base64 import urlsafe_b64encode as base64url_encode from base64 import urlsafe_b64encode as base64url_encode
from datetime import datetime
from Crypto.Random import get_random_bytes from Crypto.Random import get_random_bytes
def now(): 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: class HCDevice:
def __init__(self, ws, features, name): def __init__(self, ws, features, name):
self.ws = ws self.ws = ws
self.features = features self.features = features
self.session_id = None self.session_id = None
self.tx_msg_id = None self.tx_msg_id = None
self.device_name = "hcpy" self.device_name = "hcpy"
self.device_id = "0badcafe" self.device_id = "0badcafe"
self.debug = False self.debug = False
self.name = name self.name = name
def parse_values(self, values): def parse_values(self, values):
if not self.features: if not self.features:
return values return values
result = {} result = {}
for msg in values: for msg in values:
uid = str(msg["uid"]) uid = str(msg["uid"])
value = msg["value"] value = msg["value"]
value_str = str(value) value_str = str(value)
name = uid name = uid
status = None status = None
if uid in self.features: if uid in self.features:
status = self.features[uid] status = self.features[uid]
if status: if status:
name = status["name"] name = status["name"]
if "values" in status \ if "values" in status and value_str in status["values"]:
and value_str in status["values"]: value = status["values"][value_str]
value = status["values"][value_str]
# trim everything off the name except the last part # trim everything off the name except the last part
name = re.sub(r'^.*\.', '', name) name = re.sub(r"^.*\.", "", name)
result[name] = value result[name] = value
return result return result
# Test the feature of an appliance agains a data object # Test the feature of an appliance agains a data object
def test_feature(self, data): def test_feature(self, data):
if 'uid' not in data: if "uid" not in data:
raise Exception("{self.name}. Unable to configure appliance. UID is required.") raise Exception(
"{self.name}. Unable to configure appliance. UID is required."
)
if isinstance(data['uid'], int) is False: if isinstance(data["uid"], int) is False:
raise Exception("{self.name}. Unable to configure appliance. UID must be an integer.") raise Exception(
"{self.name}. Unable to configure appliance. UID must be an integer."
)
if 'value' not in data: if "value" not in data:
raise Exception("{self.name}. Unable to configure appliance. Value is required.") raise Exception(
"{self.name}. Unable to configure appliance. Value is required."
)
# Check if the uid is present for this appliance # Check if the uid is present for this appliance
uid = str(data['uid']) uid = str(data["uid"])
if uid not in self.features: if uid not in self.features:
raise Exception(f"{self.name}. Unable to configure appliance. UID {uid} is not valid.") 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 # check the access level of the feature
print(now(), self.name, f"Processing feature {feature['name']} with uid {uid}") print(now(), self.name, f"Processing feature {feature['name']} with uid {uid}")
if 'access' not in feature: if "access" not in feature:
raise Exception(f"{self.name}. Unable to configure appliance. Feature {feature['name']} with uid {uid} does not have access.") raise Exception(
f"{self.name}. Unable to configure appliance. Feature {feature['name']} with uid {uid} does not have access."
)
access = feature['access'].lower() access = feature["access"].lower()
if access != 'readwrite' and access != 'writeonly': 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']}.") 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 # check if selected list with values is allowed
if 'values' in feature: if "values" in feature:
if isinstance(data['value'], int) is False: if isinstance(data["value"], int) is False:
raise Exception(f"Unable to configure appliance. The value {data['value']} must be an integer. Allowed values are {feature['values']}.") raise Exception(
value = str(data['value']) # values are strings in the feature list, but always seem to be an integer. An integer must be provided f"Unable to configure appliance. The value {data['value']} must be an integer. Allowed values are {feature['values']}."
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 = 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: if "min" in feature:
min = int(feature['min']) min = int(feature["min"])
max = int(feature['max']) max = int(feature["max"])
if isinstance(data['value'], int) is False or data['value'] < min or data['value'] > max: if (
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}.") 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): def recv(self):
try: try:
buf = self.ws.recv() buf = self.ws.recv()
if buf is None: if buf is None:
return None return None
except Exception as e: except Exception as e:
print(self.name, "receive error", e, traceback.format_exc()) print(self.name, "receive error", e, traceback.format_exc())
return None return None
try: try:
return self.handle_message(buf) return self.handle_message(buf)
except Exception as e: except Exception as e:
print(self.name, "error handling msg", e, buf, traceback.format_exc()) print(self.name, "error handling msg", e, buf, traceback.format_exc())
return None return None
# reply to a POST or GET message with new data # reply to a POST or GET message with new data
def reply(self, msg, reply): def reply(self, msg, reply):
self.ws.send({ self.ws.send(
'sID': msg["sID"], {
'msgID': msg["msgID"], # same one they sent to us "sID": msg["sID"],
'resource': msg["resource"], "msgID": msg["msgID"], # same one they sent to us
'version': msg["version"], "resource": msg["resource"],
'action': 'RESPONSE', "version": msg["version"],
'data': [reply], "action": "RESPONSE",
}) "data": [reply],
}
)
# send a message to the device # send a message to the device
def get(self, resource, version=1, action="GET", data=None): def get(self, resource, version=1, action="GET", data=None):
msg = { msg = {
"sID": self.session_id, "sID": self.session_id,
"msgID": self.tx_msg_id, "msgID": self.tx_msg_id,
"resource": resource, "resource": resource,
"version": version, "version": version,
"action": action, "action": action,
} }
if data is not None: if data is not None:
if action == "POST": if action == "POST":
if self.test_feature(data) is False: if self.test_feature(data) is False:
return return
msg["data"] = [data] msg["data"] = [data]
else: else:
msg["data"] = [data] msg["data"] = [data]
try: try:
self.ws.send(msg) self.ws.send(msg)
except Exception as e: except Exception as e:
print(self.name, "Failed to send", e, msg, traceback.format_exc()) print(self.name, "Failed to send", e, msg, traceback.format_exc())
self.tx_msg_id += 1 self.tx_msg_id += 1
def handle_message(self, buf): def handle_message(self, buf):
msg = json.loads(buf) msg = json.loads(buf)
if self.debug: if self.debug:
print(now(), self.name, "RX:", msg) print(now(), self.name, "RX:", msg)
sys.stdout.flush() sys.stdout.flush()
resource = msg["resource"] resource = msg["resource"]
action = msg["action"] action = msg["action"]
values = {} values = {}
if "code" in msg: if "code" in msg:
print(now(), self.name, "ERROR", msg["code"]) print(now(), self.name, "ERROR", msg["code"])
values = { values = {
"error": msg["code"], "error": msg["code"],
"resource": msg.get("resource", ''), "resource": msg.get("resource", ""),
} }
elif action == "POST": elif action == "POST":
if resource == "/ei/initialValues": if resource == "/ei/initialValues":
# this is the first message they send to us and # this is the first message they send to us and
# establishes our session plus message ids # establishes our session plus message ids
self.session_id = msg["sID"] self.session_id = msg["sID"]
self.tx_msg_id = msg["data"][0]["edMsgID"] self.tx_msg_id = msg["data"][0]["edMsgID"]
self.reply(msg, { self.reply(
"deviceType": "Application", msg,
"deviceName": self.device_name, {
"deviceID": self.device_id, "deviceType": "Application",
}) "deviceName": self.device_name,
"deviceID": self.device_id,
},
)
# ask the device which services it supports # ask the device which services it supports
self.get("/ci/services") self.get("/ci/services")
# the clothes washer wants this, the token doesn't matter, # the clothes washer wants this, the token doesn't matter,
# although they do not handle padding characters # although they do not handle padding characters
# they send a response, not sure how to interpet it # they send a response, not sure how to interpet it
token = base64url_encode(get_random_bytes(32)).decode('UTF-8') token = base64url_encode(get_random_bytes(32)).decode("UTF-8")
token = re.sub(r'=', '', token) token = re.sub(r"=", "", token)
self.get("/ci/authentication", version=2, data={"nonce": token}) self.get("/ci/authentication", version=2, data={"nonce": token})
self.get("/ci/info", version=2) # clothes washer self.get("/ci/info", version=2) # clothes washer
self.get("/iz/info") # dish washer self.get("/iz/info") # dish washer
#self.get("/ci/tzInfo", version=2) # self.get("/ci/tzInfo", version=2)
self.get("/ni/info") self.get("/ni/info")
#self.get("/ni/config", data={"interfaceID": 0}) # self.get("/ni/config", data={"interfaceID": 0})
self.get("/ei/deviceReady", version=2, action="NOTIFY") self.get("/ei/deviceReady", version=2, action="NOTIFY")
self.get("/ro/allDescriptionChanges") self.get("/ro/allDescriptionChanges")
self.get("/ro/allDescriptionChanges") self.get("/ro/allDescriptionChanges")
self.get("/ro/allMandatoryValues") self.get("/ro/allMandatoryValues")
#self.get("/ro/values") # self.get("/ro/values")
else: else:
print(now(), self.name, "Unknown resource", resource, file=sys.stderr) print(now(), self.name, "Unknown resource", resource, file=sys.stderr)
elif action == "RESPONSE" or action == "NOTIFY": elif action == "RESPONSE" or action == "NOTIFY":
if resource == "/iz/info" or resource == "/ci/info": if resource == "/iz/info" or resource == "/ci/info":
# we could validate that this matches our machine # we could validate that this matches our machine
pass pass
elif resource == "/ro/descriptionChange" \ elif (
or resource == "/ro/allDescriptionChanges": resource == "/ro/descriptionChange"
# we asked for these but don't know have to parse yet or resource == "/ro/allDescriptionChanges"
pass ):
# we asked for these but don't know have to parse yet
pass
elif resource == "/ni/info": elif resource == "/ni/info":
# we're already talking, so maybe we don't care? # we're already talking, so maybe we don't care?
pass pass
elif resource == "/ro/allMandatoryValues" \ elif resource == "/ro/allMandatoryValues" or resource == "/ro/values":
or resource == "/ro/values": if "data" in msg:
if 'data' in msg: values = self.parse_values(msg["data"])
values = self.parse_values(msg["data"]) else:
else: print(now(), self.name, f"received {msg}")
print(now(), self.name, f"received {msg}") elif resource == "/ci/registeredDevices":
elif resource == "/ci/registeredDevices": # we don't care
# we don't care pass
pass
elif resource == "/ci/services": elif resource == "/ci/services":
self.services = {} self.services = {}
for service in msg["data"]: for service in msg["data"]:
self.services[service["service"]] = { self.services[service["service"]] = {
"version": service["version"], "version": service["version"],
} }
#print(self.name, now(), "services", self.services) # print(self.name, now(), "services", self.services)
# we should figure out which ones to query now # we should figure out which ones to query now
# if "iz" in self.services: # if "iz" in self.services:
# self.get("/iz/info", version=self.services["iz"]["version"]) # self.get("/iz/info", version=self.services["iz"]["version"])
# if "ni" in self.services: # if "ni" in self.services:
# self.get("/ni/info", version=self.services["ni"]["version"]) # self.get("/ni/info", version=self.services["ni"]["version"])
# if "ei" in self.services: # if "ei" in self.services:
# self.get("/ei/deviceReady", version=self.services["ei"]["version"], action="NOTIFY") # self.get("/ei/deviceReady", version=self.services["ei"]["version"], action="NOTIFY")
#self.get("/if/info") # self.get("/if/info")
else: else:
print(now(), self.name, "Unknown", msg) print(now(), self.name, "Unknown", msg)
# return whatever we've parsed out of it # return whatever we've parsed out of it
return values return values

View File

@@ -1,27 +1,29 @@
# Create a websocket that wraps a connection to a # Create a websocket that wraps a connection to a
# Bosh-Siemens Home Connect device # Bosh-Siemens Home Connect device
import socket
import ssl
import sslpsk
import websocket
import sys
import json import json
import re import re
import time import socket
import io import ssl
import sys
from base64 import urlsafe_b64decode as base64url from base64 import urlsafe_b64decode as base64url
from datetime import datetime from datetime import datetime
import sslpsk
import websocket
from Crypto.Cipher import AES from Crypto.Cipher import AES
from Crypto.Hash import HMAC, SHA256 from Crypto.Hash import HMAC, SHA256
from Crypto.Random import get_random_bytes from Crypto.Random import get_random_bytes
# Convience to compute an HMAC on a message # Convience to compute an HMAC on a message
def hmac(key,msg): def hmac(key, msg):
mac = HMAC.new(key, msg=msg, digestmod=SHA256).digest() mac = HMAC.new(key, msg=msg, digestmod=SHA256).digest()
return mac return mac
def now(): 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 # Monkey patch for sslpsk in pip using the old _sslobj
def _sslobj(sock): def _sslobj(sock):
@@ -29,142 +31,145 @@ def _sslobj(sock):
return sock._sslobj._sslobj return sock._sslobj._sslobj
else: else:
return sock._sslobj return sock._sslobj
sslpsk.sslpsk._sslobj = _sslobj sslpsk.sslpsk._sslobj = _sslobj
class HCSocket: class HCSocket:
def __init__(self, host, psk64, iv64=None): def __init__(self, host, psk64, iv64=None):
self.host = host self.host = host
self.psk = base64url(psk64 + '===') self.psk = base64url(psk64 + "===")
self.debug = False self.debug = False
if iv64: if iv64:
# an HTTP self-encrypted socket # an HTTP self-encrypted socket
self.http = True self.http = True
self.iv = base64url(iv64 + '===') self.iv = base64url(iv64 + "===")
self.enckey = hmac(self.psk, b'ENC') self.enckey = hmac(self.psk, b"ENC")
self.mackey = hmac(self.psk, b'MAC') self.mackey = hmac(self.psk, b"MAC")
self.port = 80 self.port = 80
self.uri = "ws://" + host + ":80/homeconnect" self.uri = "ws://" + host + ":80/homeconnect"
else: else:
self.http = False self.http = False
self.port = 443 self.port = 443
self.uri = "wss://" + host + ":443/homeconnect" self.uri = "wss://" + host + ":443/homeconnect"
# don't connect automatically so that debug etc can be set # don't connect automatically so that debug etc can be set
#self.reconnect() # self.reconnect()
# restore the encryption state for a fresh connection # restore the encryption state for a fresh connection
# this is only used by the HTTP connection # this is only used by the HTTP connection
def reset(self): def reset(self):
if not self.http: if not self.http:
return return
self.last_rx_hmac = bytes(16) self.last_rx_hmac = bytes(16)
self.last_tx_hmac = bytes(16) self.last_tx_hmac = bytes(16)
self.aes_encrypt = 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) self.aes_decrypt = AES.new(self.enckey, AES.MODE_CBC, self.iv)
# hmac an inbound or outbound message, chaining the last hmac too # hmac an inbound or outbound message, chaining the last hmac too
def hmac_msg(self, direction, enc_msg): def hmac_msg(self, direction, enc_msg):
hmac_msg = self.iv + direction + enc_msg hmac_msg = self.iv + direction + enc_msg
return hmac(self.mackey, hmac_msg)[0:16] return hmac(self.mackey, hmac_msg)[0:16]
def decrypt(self,buf): def decrypt(self, buf):
if len(buf) < 32: if len(buf) < 32:
print("Short message?", buf.hex(), file=sys.stderr) print("Short message?", buf.hex(), file=sys.stderr)
return None return None
if len(buf) % 16 != 0: if len(buf) % 16 != 0:
print("Unaligned message? probably bad", buf.hex(), file=sys.stderr) 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 # split the message into the encrypted message and the first 16-bytes of the HMAC
enc_msg = buf[0:-16] enc_msg = buf[0:-16]
their_hmac = buf[-16:] their_hmac = buf[-16:]
# compute the expected hmac on the encrypted message # compute the expected hmac on the encrypted message
our_hmac = self.hmac_msg(b'\x43' + self.last_rx_hmac, enc_msg) our_hmac = self.hmac_msg(b"\x43" + self.last_rx_hmac, enc_msg)
if their_hmac != our_hmac: if their_hmac != our_hmac:
print("HMAC failure", their_hmac.hex(), our_hmac.hex(), file=sys.stderr) print("HMAC failure", their_hmac.hex(), our_hmac.hex(), file=sys.stderr)
return None 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 # decrypt the message with CBC, so the last message block is mixed in
msg = self.aes_decrypt.decrypt(enc_msg) msg = self.aes_decrypt.decrypt(enc_msg)
# check for padding and trim it off the end # check for padding and trim it off the end
pad_len = msg[-1] pad_len = msg[-1]
if len(msg) < pad_len: if len(msg) < pad_len:
print("padding error?", msg.hex()) print("padding error?", msg.hex())
return None return None
return msg[0:-pad_len] return msg[0:-pad_len]
def encrypt(self, clear_msg): def encrypt(self, clear_msg):
# convert the UTF-8 string into a byte array # convert the UTF-8 string into a byte array
clear_msg = bytes(clear_msg, 'utf-8') clear_msg = bytes(clear_msg, "utf-8")
# pad the buffer, adding an extra block if necessary # pad the buffer, adding an extra block if necessary
pad_len = 16 - (len(clear_msg) % 16) pad_len = 16 - (len(clear_msg) % 16)
if pad_len == 1: if pad_len == 1:
pad_len += 16 pad_len += 16
pad = b'\x00' + get_random_bytes(pad_len-2) + bytearray([pad_len]) 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 # encrypt the padded message with CBC, so there is chained
# state from the last cipher block sent # state from the last cipher block sent
enc_msg = self.aes_encrypt.encrypt(clear_msg) enc_msg = self.aes_encrypt.encrypt(clear_msg)
# compute the hmac of the encrypted message, chaining the # compute the hmac of the encrypted message, chaining the
# hmac of the previous message plus direction 'E' # hmac of the previous message plus direction 'E'
self.last_tx_hmac = self.hmac_msg(b'\x45' + self.last_tx_hmac, enc_msg) self.last_tx_hmac = self.hmac_msg(b"\x45" + self.last_tx_hmac, enc_msg)
# append the new hmac to the message # append the new hmac to the message
return enc_msg + self.last_tx_hmac return enc_msg + self.last_tx_hmac
def reconnect(self): def reconnect(self):
self.reset() self.reset()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((self.host,self.port)) sock.connect((self.host, self.port))
if not self.http: if not self.http:
sock = sslpsk.wrap_socket( sock = sslpsk.wrap_socket(
sock, sock,
ssl_version = ssl.PROTOCOL_TLSv1_2, ssl_version=ssl.PROTOCOL_TLSv1_2,
ciphers = 'ECDHE-PSK-CHACHA20-POLY1305', ciphers="ECDHE-PSK-CHACHA20-POLY1305",
psk = self.psk, psk=self.psk,
) )
print(now(), "CON:", self.uri) print(now(), "CON:", self.uri)
self.ws = websocket.WebSocket() self.ws = websocket.WebSocket()
self.ws.connect(self.uri, self.ws.connect(
socket = sock, self.uri,
origin = "", socket=sock,
) origin="",
)
def send(self, msg): def send(self, msg):
buf = json.dumps(msg, separators=(',', ':') ) buf = json.dumps(msg, separators=(",", ":"))
# swap " for ' # swap " for '
buf = re.sub("'", '"', buf) buf = re.sub("'", '"', buf)
if self.debug: if self.debug:
print(now(), "TX:", buf) print(now(), "TX:", buf)
if self.http: if self.http:
self.ws.send_binary(self.encrypt(buf)) self.ws.send_binary(self.encrypt(buf))
else: else:
self.ws.send(buf) self.ws.send(buf)
def recv(self): def recv(self):
buf = self.ws.recv() buf = self.ws.recv()
if buf is None or buf == "": if buf is None or buf == "":
return None return None
if self.http: if self.http:
buf = self.decrypt(buf) buf = self.decrypt(buf)
if buf is None: if buf is None:
return None return None
if self.debug: if self.debug:
print(now(), "RX:", buf) print(now(), "RX:", buf)
return buf return buf

View File

@@ -7,7 +7,6 @@
# #
import sys import sys
import json
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
##################### #####################
@@ -16,95 +15,96 @@ import xml.etree.ElementTree as ET
# list of UIDs # list of UIDs
# #
def parse_xml_list(codes, entries, enums): def parse_xml_list(codes, entries, enums):
for el in entries: for el in entries:
# not sure how to parse refCID and refDID # not sure how to parse refCID and refDID
uid = int(el.attrib["uid"], 16) uid = int(el.attrib["uid"], 16)
if not uid in codes: if not uid in codes:
print("UID", uid, " not known!", file=sys.stderr) print("UID", uid, " not known!", file=sys.stderr)
data = codes[uid]; data = codes[uid]
if "uid" in codes: if "uid" in codes:
print("UID", uid, " used twice?", data, file=sys.stderr) print("UID", uid, " used twice?", data, file=sys.stderr)
for key in el.attrib: for key in el.attrib:
data[key] = el.attrib[key] data[key] = el.attrib[key]
# clean up later # clean up later
#del data["uid"] # del data["uid"]
if "enumerationType" in el.attrib: if "enumerationType" in el.attrib:
del data["enumerationType"] del data["enumerationType"]
enum_id = int(el.attrib["enumerationType"], 16) enum_id = int(el.attrib["enumerationType"], 16)
data["values"] = enums[enum_id]["values"] data["values"] = enums[enum_id]["values"]
# codes[uid] = data
#codes[uid] = data
def parse_machine_description(entries): def parse_machine_description(entries):
description = {} description = {}
for el in entries: for el in entries:
prefix, has_namespace, tag = el.tag.partition('}') prefix, has_namespace, tag = el.tag.partition("}")
if tag != "pairableDeviceTypes": if tag != "pairableDeviceTypes":
description[tag] = el.text description[tag] = el.text
return description return description
def xml2json(features_xml,description_xml): def xml2json(features_xml, description_xml):
# the feature file has features, errors, and enums # the feature file has features, errors, and enums
# for now the ordering is hardcoded # for now the ordering is hardcoded
featuremapping = ET.fromstring(features_xml) #.getroot() featuremapping = ET.fromstring(features_xml) # .getroot()
description = ET.fromstring(description_xml) #.getroot() description = ET.fromstring(description_xml) # .getroot()
##################### #####################
# #
# Parse the feature file # Parse the feature file
# #
features = {} features = {}
errors = {} errors = {}
enums = {} enums = {}
# Features are all possible UIDs # Features are all possible UIDs
for child in featuremapping[1]: #.iter('feature'): for child in featuremapping[1]: # .iter('feature'):
uid = int(child.attrib["refUID"], 16) uid = int(child.attrib["refUID"], 16)
name = child.text name = child.text
features[uid] = { features[uid] = {
"name": name, "name": name,
} }
# Errors # Errors
for child in featuremapping[2]: for child in featuremapping[2]:
uid = int(child.attrib["refEID"], 16) uid = int(child.attrib["refEID"], 16)
name = child.text name = child.text
errors[uid] = name errors[uid] = name
# Enums # Enums
for child in featuremapping[3]: for child in featuremapping[3]:
uid = int(child.attrib["refENID"], 16) uid = int(child.attrib["refENID"], 16)
enum_name = child.attrib["enumKey"] enum_name = child.attrib["enumKey"]
values = {} values = {}
for v in child: for v in child:
value = int(v.attrib["refValue"]) value = int(v.attrib["refValue"])
name = v.text name = v.text
values[value] = name values[value] = name
enums[uid] = { enums[uid] = {
"name": enum_name, "name": enum_name,
"values": values, "values": values,
} }
for i in range(4, 8):
parse_xml_list(features, description[i], enums)
for i in range(4,8): # remove the duplicate uid field
parse_xml_list(features, description[i], enums) for uid in features:
if "uid" in features[uid]:
del features[uid]["uid"]
# remove the duplicate uid field return {
for uid in features: "description": parse_machine_description(description[3]),
if "uid" in features[uid]: "features": features,
del features[uid]["uid"] }
return {
"description": parse_machine_description(description[3]),
"features": features,
}

304
hc-login
View File

@@ -3,39 +3,37 @@
# https://github.com/openid/AppAuth-Android # https://github.com/openid/AppAuth-Android
# A really nice walk through of how it works is: # 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 # 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 io
import json
import re import re
import sys import sys
import json
from time import time
from base64 import b64decode as base64_decode
from base64 import urlsafe_b64encode as base64url_encode from base64 import urlsafe_b64encode as base64url_encode
from bs4 import BeautifulSoup from urllib.parse import parse_qs, urlencode, urlparse
from Crypto.Random import get_random_bytes
from Crypto.Hash import SHA256
from zipfile import ZipFile 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) # 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. # 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. # 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. # You must initialize logging, otherwise you'll not see debug output.
#logging.basicConfig() # logging.basicConfig()
#logging.getLogger().setLevel(logging.DEBUG) # logging.getLogger().setLevel(logging.DEBUG)
#requests_log = logging.getLogger("requests.packages.urllib3") # requests_log = logging.getLogger("requests.packages.urllib3")
#requests_log.setLevel(logging.DEBUG) # requests_log.setLevel(logging.DEBUG)
#requests_log.propagate = True # requests_log.propagate = True
def debug(*args): def debug(*args):
print(*args, file=sys.stderr) print(*args, file=sys.stderr)
email = sys.argv[1] email = sys.argv[1]
password = sys.argv[2] password = sys.argv[2]
@@ -45,8 +43,8 @@ headers = {"User-Agent": "hc-login/1.0"}
session = requests.Session() session = requests.Session()
session.headers.update(headers) session.headers.update(headers)
base_url = 'https://api.home-connect.com/security/oauth/' base_url = "https://api.home-connect.com/security/oauth/"
asset_url = 'https://prod.reu.rest.homeconnectegw.com/' asset_url = "https://prod.reu.rest.homeconnectegw.com/"
##############3 ##############3
# #
@@ -55,65 +53,82 @@ asset_url = 'https://prod.reu.rest.homeconnectegw.com/'
# even after the singlekey detour. # even after the singlekey detour.
# #
# The app_id and scope are hardcoded in the application # The app_id and scope are hardcoded in the application
app_id = '9B75AC9EC512F36C84256AC47D813E2C1DD0D6520DF774B020E1E6E2EB29B1F3' app_id = "9B75AC9EC512F36C84256AC47D813E2C1DD0D6520DF774B020E1E6E2EB29B1F3"
scope = ["ReadAccount","Settings","IdentifyAppliance","Control","DeleteAppliance","WriteAppliance","ReadOrigApi","Monitor","WriteOrigApi","Images"] scope = [
scope = ["ReadOrigApi",] "ReadAccount",
"Settings",
"IdentifyAppliance",
"Control",
"DeleteAppliance",
"WriteAppliance",
"ReadOrigApi",
"Monitor",
"WriteOrigApi",
"Images",
]
scope = [
"ReadOrigApi",
]
def b64(b): 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): 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)) verifier = b64(get_random_bytes(32))
login_query = { login_query = {
"response_type": "code", "response_type": "code",
"prompt": "login", "prompt": "login",
"code_challenge": b64(SHA256.new(verifier.encode('UTF-8')).digest()), "code_challenge": b64(SHA256.new(verifier.encode("UTF-8")).digest()),
"code_challenge_method": "S256", "code_challenge_method": "S256",
"client_id": app_id, "client_id": app_id,
"scope": ' '.join(scope), "scope": " ".join(scope),
"nonce": b64random(16), "nonce": b64random(16),
"state": b64random(16), "state": b64random(16),
"redirect_uri": 'hcauth://auth/prod', "redirect_uri": "hcauth://auth/prod",
"redirect_target": 'icore', "redirect_target": "icore",
} }
loginpage_url = base_url + 'authorize?' + urlencode(login_query) loginpage_url = base_url + "authorize?" + urlencode(login_query)
token_url = base_url + 'token' token_url = base_url + "token"
debug(f"{loginpage_url=}") debug(f"{loginpage_url=}")
r = session.get(loginpage_url) r = session.get(loginpage_url)
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
print("error fetching login url!", loginpage_url, r.text, file=sys.stderr) print("error fetching login url!", loginpage_url, r.text, file=sys.stderr)
exit(1) exit(1)
# get the session from the text # get the session from the text
if not (match := re.search(r'"sessionId" value="(.*?)"', r.text)): if not (match := re.search(r'"sessionId" value="(.*?)"', r.text)):
print("Unable to find session id in login page") print("Unable to find session id in login page")
exit(1) exit(1)
session_id = match[1] session_id = match[1]
if not (match := re.search(r'"sessionData" value="(.*?)"', r.text)): if not (match := re.search(r'"sessionData" value="(.*?)"', r.text)):
print("Unable to find session data in login page") print("Unable to find session data in login page")
exit(1) exit(1)
session_data = match[1] session_data = match[1]
debug("--------") 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 # single key host to start the new login flow
singlekey_host = 'https://singlekey-id.com' singlekey_host = "https://singlekey-id.com"
login_url = singlekey_host + '/auth/en-us/log-in/' login_url = singlekey_host + "/auth/en-us/log-in/"
preauth_url = singlekey_host + "/auth/connect/authorize" preauth_url = singlekey_host + "/auth/connect/authorize"
preauth_query = { preauth_query = {
"client_id": "11F75C04-21C2-4DA9-A623-228B54E9A256", "client_id": "11F75C04-21C2-4DA9-A623-228B54E9A256",
"redirect_uri": "https://api.home-connect.com/security/oauth/redirect_target", "redirect_uri": "https://api.home-connect.com/security/oauth/redirect_target",
"response_type": "code", "response_type": "code",
"scope": "openid email profile offline_access homeconnect.general", "scope": "openid email profile offline_access homeconnect.general",
"prompt": "login", "prompt": "login",
"style_id": "bsh_hc_01", "style_id": "bsh_hc_01",
"state": '{"session_id":"' + session_id + '"}', # important: no spaces! "state": '{"session_id":"' + session_id + '"}', # important: no spaces!
} }
# fetch the preauth state to get the final callback url # 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 # loop until we have the callback url
while True: while True:
debug(f"next {preauth_url=}") debug(f"next {preauth_url=}")
r = session.get(preauth_url, allow_redirects=False) r = session.get(preauth_url, allow_redirects=False)
if r.status_code == 200: if r.status_code == 200:
break break
if r.status_code > 300 and r.status_code < 400: if r.status_code > 300 and r.status_code < 400:
preauth_url = r.headers["location"] preauth_url = r.headers["location"]
# Make relative locations absolute # Make relative locations absolute
if not bool(urlparse(preauth_url).netloc): if not bool(urlparse(preauth_url).netloc):
preauth_url = singlekey_host + preauth_url preauth_url = singlekey_host + preauth_url
continue continue
print(f"2: {preauth_url=}: failed to fetch {r} {r.text}", file=sys.stderr) print(f"2: {preauth_url=}: failed to fetch {r} {r.text}", file=sys.stderr)
exit(1) exit(1)
# get the ReturnUrl from the response # get the ReturnUrl from the response
query = parse_qs(urlparse(preauth_url).query) query = parse_qs(urlparse(preauth_url).query)
@@ -140,36 +155,55 @@ return_url = query["ReturnUrl"][0]
debug(f"{return_url=}") debug(f"{return_url=}")
if "X-CSRF-FORM-TOKEN" in r.cookies: 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) session.headers.update(headers)
debug("--------") debug("--------")
soup = BeautifulSoup(r.text, 'html.parser') soup = BeautifulSoup(r.text, "html.parser")
requestVerificationToken = soup.find('input', {'name': '__RequestVerificationToken'}).get('value') requestVerificationToken = soup.find(
r = session.post(preauth_url, data={"UserIdentifierInput.EmailInput.StringValue": email, "__RequestVerificationToken": requestVerificationToken }, allow_redirects=False) "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): 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) r = session.get(password_url, allow_redirects=False)
soup = BeautifulSoup(r.text, 'html.parser') soup = BeautifulSoup(r.text, "html.parser")
requestVerificationToken = soup.find('input', {'name': '__RequestVerificationToken'}).get('value') 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("/"): if return_url.startswith("/"):
return_url = singlekey_host + return_url return_url = singlekey_host + return_url
while True: while True:
r = session.get(return_url, allow_redirects=False) r = session.get(return_url, allow_redirects=False)
debug(f"{return_url=}, {r} {r.text}") debug(f"{return_url=}, {r} {r.text}")
if r.status_code != 302: if r.status_code != 302:
break break
return_url = r.headers["location"] return_url = r.headers["location"]
if return_url.startswith("hcauth://"): if return_url.startswith("hcauth://"):
break break
debug(f"{return_url=}") debug(f"{return_url=}")
debug("--------") debug("--------")
@@ -178,92 +212,92 @@ url = urlparse(return_url)
query = parse_qs(url.query) query = parse_qs(url.query)
code = query.get("code")[0] code = query.get("code")[0]
state = query.get("state")[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=}") debug(f"{code=} {grant_type=} {state=}")
auth_url = base_url + 'login' auth_url = base_url + "login"
token_url = base_url + 'token' token_url = base_url + "token"
token_fields = { token_fields = {
"grant_type": grant_type, "grant_type": grant_type,
"client_id": app_id, "client_id": app_id,
"code_verifier": verifier, "code_verifier": verifier,
"code": code, "code": code,
"redirect_uri": login_query["redirect_uri"], "redirect_uri": login_query["redirect_uri"],
} }
debug(f"{token_url=} {token_fields=}") debug(f"{token_url=} {token_fields=}")
r = requests.post(token_url, data=token_fields, allow_redirects=False) r = requests.post(token_url, data=token_fields, allow_redirects=False)
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
print("Bad code?", file=sys.stderr) print("Bad code?", file=sys.stderr)
print(r.headers, r.text) print(r.headers, r.text)
exit(1) exit(1)
debug('--------- got token page ----------') debug("--------- got token page ----------")
token = json.loads(r.text)["access_token"] token = json.loads(r.text)["access_token"]
debug(f"Received access {token=}") debug(f"Received access {token=}")
headers = { headers = {
"Authorization": "Bearer " + token, "Authorization": "Bearer " + token,
} }
# now we can fetch the rest of the account info # now we can fetch the rest of the account info
r = requests.get(asset_url + "account/details", headers=headers) r = requests.get(asset_url + "account/details", headers=headers)
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
print("unable to fetch account details", file=sys.stderr) print("unable to fetch account details", file=sys.stderr)
print(r.headers, r.text) print(r.headers, r.text)
exit(1) exit(1)
#print(r.text) # print(r.text)
account = json.loads(r.text) account = json.loads(r.text)
configs = [] configs = []
print(account, file=sys.stderr) print(account, file=sys.stderr)
for app in account["data"]["homeAppliances"]: for app in account["data"]["homeAppliances"]:
app_brand = app["brand"] app_brand = app["brand"]
app_type = app["type"] app_type = app["type"]
app_id = app["identifier"] app_id = app["identifier"]
config = { config = {
"name": app_type.lower(), "name": app_type.lower(),
} }
configs.append(config) configs.append(config)
if "tls" in app: if "tls" in app:
# fancy machine with TLS support # fancy machine with TLS support
config["host"] =app_brand + "-" + app_type + "-" + app_id config["host"] = app_brand + "-" + app_type + "-" + app_id
config["key"] = app["tls"]["key"] config["key"] = app["tls"]["key"]
else: else:
# less fancy machine with HTTP support # less fancy machine with HTTP support
config["host"] = app_id config["host"] = app_id
config["key"] = app["aes"]["key"] config["key"] = app["aes"]["key"]
config["iv"] = app["aes"]["iv"] config["iv"] = app["aes"]["iv"]
# Fetch the XML zip file for this device # Fetch the XML zip file for this device
app_url = asset_url + "api/iddf/v1/iddf/" + app_id app_url = asset_url + "api/iddf/v1/iddf/" + app_id
print("fetching", app_url, file=sys.stderr) print("fetching", app_url, file=sys.stderr)
r = requests.get(app_url, headers=headers) r = requests.get(app_url, headers=headers)
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
print(app_id, ": unable to fetch machine description?") print(app_id, ": unable to fetch machine description?")
next next
# we now have a zip file with XML, let's unpack them # we now have a zip file with XML, let's unpack them
content = r.content content = r.content
print(app_url + ": " + app_id + ".zip", file=sys.stderr) print(app_url + ": " + app_id + ".zip", file=sys.stderr)
with open(app_id + ".zip", "wb") as f: with open(app_id + ".zip", "wb") as f:
f.write(content) f.write(content)
z = ZipFile(io.BytesIO(content)) z = ZipFile(io.BytesIO(content))
#print(z.infolist()) # print(z.infolist())
features = z.open(app_id + "_FeatureMapping.xml").read() features = z.open(app_id + "_FeatureMapping.xml").read()
description = z.open(app_id + "_DeviceDescription.xml").read() description = z.open(app_id + "_DeviceDescription.xml").read()
machine = xml2json(features, description) machine = xml2json(features, description)
config["description"] = machine["description"] config["description"] = machine["description"]
config["features"] = machine["features"] config["features"] = machine["features"]
print(json.dumps(configs, indent=4)) print(json.dumps(configs, indent=4))

200
hc2mqtt
View File

@@ -2,9 +2,9 @@
# Contact Bosh-Siemens Home Connect devices # Contact Bosh-Siemens Home Connect devices
# and connect their messages to the mqtt server # and connect their messages to the mqtt server
import json import json
import ssl
import sys import sys
import time import time
import ssl
from threading import Thread from threading import Thread
import click import click
@@ -14,6 +14,7 @@ import paho.mqtt.client as mqtt
from HCDevice import HCDevice from HCDevice import HCDevice
from HCSocket import HCSocket, now from HCSocket import HCSocket, now
@click.command() @click.command()
@click.option("-d", "--devices_file", default="config/devices.json") @click.option("-d", "--devices_file", default="config/devices.json")
@click.option("-h", "--mqtt_host", default="localhost") @click.option("-h", "--mqtt_host", default="localhost")
@@ -27,120 +28,147 @@ from HCSocket import HCSocket, now
@click.option("--mqtt_keyfile") @click.option("--mqtt_keyfile")
@click.option("--mqtt_clientname", default="hcpy") @click.option("--mqtt_clientname", default="hcpy")
@click_config_file.configuration_option() @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, with open(devices_file, "r") as f:
mqtt_password: str, mqtt_ssl: bool, mqtt_cafile: str, mqtt_certfile: str, mqtt_keyfile: str, mqtt_clientname: str): devices = json.load(f)
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: client = mqtt.Client(mqtt_clientname)
devices = json.load(f)
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: if mqtt_ssl:
client.username_pw_set(mqtt_username, mqtt_password) 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: client.connect(host=mqtt_host, port=mqtt_port, keepalive=70)
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()
for device in devices: client.loop_forever()
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 # Map their value names to easier state names
topics = { topics = {
"InternalError": "Error", "InternalError": "Error",
"FatalErrorOccured": "Error", "FatalErrorOccured": "Error",
} }
global dev global dev
dev = {} dev = {}
def client_connect(client, device, mqtt_topic): def client_connect(client, device, mqtt_topic):
def on_message(client, userdata, msg): def on_message(client, userdata, msg):
print(msg.topic) print(msg.topic)
mqtt_state = msg.payload.decode() mqtt_state = msg.payload.decode()
mqtt_topic = msg.topic.split('/') mqtt_topic = msg.topic.split("/")
print(now(), f"received mqtt message {mqtt_state}") print(now(), f"received mqtt message {mqtt_state}")
try: try:
msg = json.loads(mqtt_state) msg = json.loads(mqtt_state)
if 'uid' in msg: if "uid" in msg:
dev[mqtt_topic[-2]].get("/ro/values", 1, "POST", msg) dev[mqtt_topic[-2]].get("/ro/values", 1, "POST", msg)
else: else:
raise Exception(f"Payload {msg} is not correctly formatted") raise Exception(f"Payload {msg} is not correctly formatted")
except Exception as e: except Exception as e:
print("ERROR", e, file=sys.stderr) print("ERROR", e, file=sys.stderr)
host = device["host"] host = device["host"]
device_topics = topics device_topics = topics
for value in device["features"]: for value in device["features"]:
if "access" in device["features"][value] and "read" in device["features"][value]['access'].lower(): if (
name = device["features"][value]['name'].split(".") "access" in device["features"][value]
device_topics[name[-1]] = name[-1] and "read" in device["features"][value]["access"].lower()
device_topics[value] = name[-1] #sometimes the returned key is a digit, making translation possible ):
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 = {} state = {}
for topic in device_topics: for topic in device_topics:
if not topic.isdigit(): #We only want the named topics if not topic.isdigit(): # We only want the named topics
state[device_topics[topic]] = None state[device_topics[topic]] = None
mqtt_set_topic = mqtt_topic + "/set" mqtt_set_topic = mqtt_topic + "/set"
print(now(), device["name"], f"set topic: {mqtt_set_topic}") print(now(), device["name"], f"set topic: {mqtt_set_topic}")
client.subscribe(mqtt_set_topic) client.subscribe(mqtt_set_topic)
client.on_message = on_message client.on_message = on_message
while True: while True:
try: try:
print(now(), device["name"], f"connecting to {host}") print(now(), device["name"], f"connecting to {host}")
ws = HCSocket(host, device["key"], device.get("iv",None)) ws = HCSocket(host, device["key"], device.get("iv", None))
dev[device["name"]] = HCDevice(ws, device.get("features", None), device["name"]) dev[device["name"]] = HCDevice(
ws, device.get("features", None), device["name"]
)
#ws.debug = True # ws.debug = True
ws.reconnect() ws.reconnect()
while True: while True:
msg = dev[device["name"]].recv() msg = dev[device["name"]].recv()
if msg is None: if msg is None:
break break
if len(msg) > 0: if len(msg) > 0:
print(now(), device["name"], msg) print(now(), device["name"], msg)
update = False update = False
for topic in device_topics: for topic in device_topics:
value = msg.get(topic, None) value = msg.get(topic, None)
if value is None: if value is None:
continue continue
# new_topic = topics[topic] # new_topic = topics[topic]
# if new_topic == "remaining": # if new_topic == "remaining":
# state["remainingseconds"] = value # state["remainingseconds"] = value
# value = "%d:%02d" % (value / 60 / 60, (value / 60) % 60) # value = "%d:%02d" % (value / 60 / 60, (value / 60) % 60)
new_topic = device_topics[topic] new_topic = device_topics[topic]
state[new_topic] = value state[new_topic] = value
update = True update = True
if not update: if not update:
continue continue
msg = json.dumps(state) msg = json.dumps(state)
print(now(), device["name"], f"publish to {mqtt_topic} with {msg}") print(now(), device["name"], f"publish to {mqtt_topic} with {msg}")
client.publish(mqtt_topic + "/state", msg) client.publish(mqtt_topic + "/state", msg)
except Exception as e: except Exception as e:
print("ERROR", host, e, file=sys.stderr) print("ERROR", host, e, file=sys.stderr)
time.sleep(5)
time.sleep(5)
if __name__ == "__main__": if __name__ == "__main__":
hc2mqtt() hc2mqtt()