[pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
This commit is contained in:
127
HCDevice.py
127
HCDevice.py
@@ -39,20 +39,20 @@
|
|||||||
#
|
#
|
||||||
# /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
|
||||||
@@ -83,56 +83,79 @@ class HCDevice:
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -153,14 +176,16 @@ class HCDevice:
|
|||||||
|
|
||||||
# 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):
|
||||||
@@ -201,7 +226,7 @@ class HCDevice:
|
|||||||
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":
|
||||||
@@ -210,11 +235,14 @@ class HCDevice:
|
|||||||
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(
|
||||||
|
msg,
|
||||||
|
{
|
||||||
"deviceType": "Application",
|
"deviceType": "Application",
|
||||||
"deviceName": self.device_name,
|
"deviceName": self.device_name,
|
||||||
"deviceID": self.device_id,
|
"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")
|
||||||
@@ -222,8 +250,8 @@ class HCDevice:
|
|||||||
# 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
|
||||||
@@ -244,8 +272,10 @@ class HCDevice:
|
|||||||
# 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"
|
||||||
|
or resource == "/ro/allDescriptionChanges"
|
||||||
|
):
|
||||||
# we asked for these but don't know have to parse yet
|
# we asked for these but don't know have to parse yet
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -253,9 +283,8 @@ class HCDevice:
|
|||||||
# 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}")
|
||||||
|
|||||||
41
HCSocket.py
41
HCSocket.py
@@ -1,49 +1,53 @@
|
|||||||
# 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):
|
||||||
if (3, 5) <= sys.version_info <= (3, 7):
|
if (3, 5) <= sys.version_info <= (3, 7):
|
||||||
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:
|
||||||
@@ -82,7 +86,7 @@ class HCSocket:
|
|||||||
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)
|
||||||
@@ -103,13 +107,13 @@ class HCSocket:
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -119,7 +123,7 @@ class HCSocket:
|
|||||||
|
|
||||||
# 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
|
||||||
@@ -133,19 +137,20 @@ class HCSocket:
|
|||||||
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(
|
||||||
|
self.uri,
|
||||||
socket=sock,
|
socket=sock,
|
||||||
origin="",
|
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:
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import json
|
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
#####################
|
#####################
|
||||||
@@ -16,6 +15,7 @@ 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
|
||||||
@@ -24,7 +24,7 @@ def parse_xml_list(codes, entries, enums):
|
|||||||
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)
|
||||||
|
|
||||||
@@ -41,11 +41,12 @@ def parse_xml_list(codes, entries, enums):
|
|||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
@@ -95,7 +96,6 @@ def xml2json(features_xml,description_xml):
|
|||||||
"values": values,
|
"values": values,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
for i in range(4, 8):
|
for i in range(4, 8):
|
||||||
parse_xml_list(features, description[i], enums)
|
parse_xml_list(features, description[i], enums)
|
||||||
|
|
||||||
|
|||||||
106
hc-login
106
hc-login
@@ -3,28 +3,24 @@
|
|||||||
# 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.
|
||||||
@@ -34,9 +30,11 @@ import http.client as http_client
|
|||||||
# 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,32 +53,49 @@ 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)
|
||||||
@@ -102,8 +117,8 @@ 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 = {
|
||||||
@@ -145,19 +160,38 @@ 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
|
||||||
@@ -182,8 +216,8 @@ 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,
|
||||||
@@ -201,7 +235,7 @@ if r.status_code != requests.codes.ok:
|
|||||||
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=}")
|
||||||
|
|||||||
54
hc2mqtt
54
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,11 +28,23 @@ 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(
|
||||||
def hc2mqtt(devices_file: str, mqtt_host: str, mqtt_prefix: str, mqtt_port: int, mqtt_username: str,
|
devices_file: str,
|
||||||
mqtt_password: str, mqtt_ssl: bool, mqtt_cafile: str, mqtt_certfile: str, mqtt_keyfile: str, mqtt_clientname: str):
|
mqtt_host: str,
|
||||||
click.echo(f"Hello {devices_file=} {mqtt_host=} {mqtt_prefix=} {mqtt_port=} {mqtt_username=} {mqtt_password=} "
|
mqtt_prefix: str,
|
||||||
f"{mqtt_ssl=} {mqtt_cafile=} {mqtt_certfile=} {mqtt_keyfile=} {mqtt_clientname=}")
|
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:
|
with open(devices_file, "r") as f:
|
||||||
devices = json.load(f)
|
devices = json.load(f)
|
||||||
@@ -43,7 +56,12 @@ def hc2mqtt(devices_file: str, mqtt_host: str, mqtt_prefix: str, mqtt_port: int,
|
|||||||
|
|
||||||
if mqtt_ssl:
|
if mqtt_ssl:
|
||||||
if mqtt_cafile and mqtt_certfile and mqtt_keyfile:
|
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)
|
client.tls_set(
|
||||||
|
ca_certs=mqtt_cafile,
|
||||||
|
certfile=mqtt_certfile,
|
||||||
|
keyfile=mqtt_keyfile,
|
||||||
|
cert_reqs=ssl.CERT_REQUIRED,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
client.tls_set(cert_reqs=ssl.CERT_NONE)
|
client.tls_set(cert_reqs=ssl.CERT_NONE)
|
||||||
|
|
||||||
@@ -57,6 +75,7 @@ def hc2mqtt(devices_file: str, mqtt_host: str, mqtt_prefix: str, mqtt_port: int,
|
|||||||
|
|
||||||
client.loop_forever()
|
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",
|
||||||
@@ -65,15 +84,16 @@ topics = {
|
|||||||
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")
|
||||||
@@ -84,10 +104,15 @@ def client_connect(client, device, mqtt_topic):
|
|||||||
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]
|
||||||
|
and "read" in device["features"][value]["access"].lower()
|
||||||
|
):
|
||||||
|
name = device["features"][value]["name"].split(".")
|
||||||
device_topics[name[-1]] = name[-1]
|
device_topics[name[-1]] = name[-1]
|
||||||
device_topics[value] = name[-1] #sometimes the returned key is a digit, making translation possible
|
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:
|
||||||
@@ -103,7 +128,9 @@ def client_connect(client, device, mqtt_topic):
|
|||||||
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()
|
||||||
@@ -142,5 +169,6 @@ def client_connect(client, device, mqtt_topic):
|
|||||||
|
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
hc2mqtt()
|
hc2mqtt()
|
||||||
|
|||||||
Reference in New Issue
Block a user