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