[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
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

View File

@@ -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

View File

@@ -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,
}

302
hc-login
View File

@@ -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
# 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))

200
hc2mqtt
View File

@@ -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()