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