[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
|
||||
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import io
|
||||
import sys
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from base64 import urlsafe_b64encode as base64url_encode
|
||||
from datetime import datetime
|
||||
|
||||
from Crypto.Random import get_random_bytes
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||
|
||||
|
||||
class HCDevice:
|
||||
def __init__(self, ws, features, name):
|
||||
self.ws = ws
|
||||
@@ -83,56 +83,79 @@ class HCDevice:
|
||||
|
||||
if status:
|
||||
name = status["name"]
|
||||
if "values" in status \
|
||||
and value_str in status["values"]:
|
||||
if "values" in status and value_str in status["values"]:
|
||||
value = status["values"][value_str]
|
||||
|
||||
# trim everything off the name except the last part
|
||||
name = re.sub(r'^.*\.', '', name)
|
||||
name = re.sub(r"^.*\.", "", name)
|
||||
result[name] = value
|
||||
|
||||
return result
|
||||
|
||||
# Test the feature of an appliance agains a data object
|
||||
def test_feature(self, data):
|
||||
if 'uid' not in data:
|
||||
raise Exception("{self.name}. Unable to configure appliance. UID is required.")
|
||||
if "uid" not in data:
|
||||
raise Exception(
|
||||
"{self.name}. Unable to configure appliance. UID is required."
|
||||
)
|
||||
|
||||
if isinstance(data['uid'], int) is False:
|
||||
raise Exception("{self.name}. Unable to configure appliance. UID must be an integer.")
|
||||
if isinstance(data["uid"], int) is False:
|
||||
raise Exception(
|
||||
"{self.name}. Unable to configure appliance. UID must be an integer."
|
||||
)
|
||||
|
||||
if 'value' not in data:
|
||||
raise Exception("{self.name}. Unable to configure appliance. Value is required.")
|
||||
if "value" not in data:
|
||||
raise Exception(
|
||||
"{self.name}. Unable to configure appliance. Value is required."
|
||||
)
|
||||
|
||||
# Check if the uid is present for this appliance
|
||||
uid = str(data['uid'])
|
||||
uid = str(data["uid"])
|
||||
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]
|
||||
|
||||
# check the access level of the feature
|
||||
print(now(), self.name, f"Processing feature {feature['name']} with uid {uid}")
|
||||
if 'access' not in feature:
|
||||
raise Exception(f"{self.name}. Unable to configure appliance. Feature {feature['name']} with uid {uid} does not have access.")
|
||||
if "access" not in feature:
|
||||
raise Exception(
|
||||
f"{self.name}. Unable to configure appliance. Feature {feature['name']} with uid {uid} does not have access."
|
||||
)
|
||||
|
||||
access = feature['access'].lower()
|
||||
if access != 'readwrite' and access != 'writeonly':
|
||||
raise Exception(f"{self.name}. Unable to configure appliance. Feature {feature['name']} with uid {uid} has got access {feature['access']}.")
|
||||
access = feature["access"].lower()
|
||||
if access != "readwrite" and access != "writeonly":
|
||||
raise Exception(
|
||||
f"{self.name}. Unable to configure appliance. Feature {feature['name']} with uid {uid} has got access {feature['access']}."
|
||||
)
|
||||
|
||||
# check if selected list with values is allowed
|
||||
if 'values' in feature:
|
||||
if isinstance(data['value'], int) is False:
|
||||
raise Exception(f"Unable to configure appliance. The value {data['value']} must be an integer. Allowed values are {feature['values']}.")
|
||||
value = str(data['value']) # values are strings in the feature list, but always seem to be an integer. An integer must be provided
|
||||
if value not in feature['values']:
|
||||
raise Exception(f"{self.name}. Unable to configure appliance. Value {data['value']} is not a valid value. Allowed values are {feature['values']}.")
|
||||
if "values" in feature:
|
||||
if isinstance(data["value"], int) is False:
|
||||
raise Exception(
|
||||
f"Unable to configure appliance. The value {data['value']} must be an integer. Allowed values are {feature['values']}."
|
||||
)
|
||||
value = str(
|
||||
data["value"]
|
||||
) # values are strings in the feature list, but always seem to be an integer. An integer must be provided
|
||||
if value not in feature["values"]:
|
||||
raise Exception(
|
||||
f"{self.name}. Unable to configure appliance. Value {data['value']} is not a valid value. Allowed values are {feature['values']}."
|
||||
)
|
||||
|
||||
if 'min' in feature:
|
||||
min = int(feature['min'])
|
||||
max = int(feature['max'])
|
||||
if isinstance(data['value'], int) is False or data['value'] < min or data['value'] > max:
|
||||
raise Exception(f"{self.name}. Unable to configure appliance. Value {data['value']} is not a valid value. The value must be an integer in the range {min} and {max}.")
|
||||
if "min" in feature:
|
||||
min = int(feature["min"])
|
||||
max = int(feature["max"])
|
||||
if (
|
||||
isinstance(data["value"], int) is False
|
||||
or data["value"] < min
|
||||
or data["value"] > max
|
||||
):
|
||||
raise Exception(
|
||||
f"{self.name}. Unable to configure appliance. Value {data['value']} is not a valid value. The value must be an integer in the range {min} and {max}."
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -153,14 +176,16 @@ class HCDevice:
|
||||
|
||||
# reply to a POST or GET message with new data
|
||||
def reply(self, msg, reply):
|
||||
self.ws.send({
|
||||
'sID': msg["sID"],
|
||||
'msgID': msg["msgID"], # same one they sent to us
|
||||
'resource': msg["resource"],
|
||||
'version': msg["version"],
|
||||
'action': 'RESPONSE',
|
||||
'data': [reply],
|
||||
})
|
||||
self.ws.send(
|
||||
{
|
||||
"sID": msg["sID"],
|
||||
"msgID": msg["msgID"], # same one they sent to us
|
||||
"resource": msg["resource"],
|
||||
"version": msg["version"],
|
||||
"action": "RESPONSE",
|
||||
"data": [reply],
|
||||
}
|
||||
)
|
||||
|
||||
# send a message to the device
|
||||
def get(self, resource, version=1, action="GET", data=None):
|
||||
@@ -201,7 +226,7 @@ class HCDevice:
|
||||
print(now(), self.name, "ERROR", msg["code"])
|
||||
values = {
|
||||
"error": msg["code"],
|
||||
"resource": msg.get("resource", ''),
|
||||
"resource": msg.get("resource", ""),
|
||||
}
|
||||
elif action == "POST":
|
||||
if resource == "/ei/initialValues":
|
||||
@@ -210,11 +235,14 @@ class HCDevice:
|
||||
self.session_id = msg["sID"]
|
||||
self.tx_msg_id = msg["data"][0]["edMsgID"]
|
||||
|
||||
self.reply(msg, {
|
||||
self.reply(
|
||||
msg,
|
||||
{
|
||||
"deviceType": "Application",
|
||||
"deviceName": self.device_name,
|
||||
"deviceID": self.device_id,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
# ask the device which services it supports
|
||||
self.get("/ci/services")
|
||||
@@ -222,8 +250,8 @@ class HCDevice:
|
||||
# the clothes washer wants this, the token doesn't matter,
|
||||
# although they do not handle padding characters
|
||||
# they send a response, not sure how to interpet it
|
||||
token = base64url_encode(get_random_bytes(32)).decode('UTF-8')
|
||||
token = re.sub(r'=', '', token)
|
||||
token = base64url_encode(get_random_bytes(32)).decode("UTF-8")
|
||||
token = re.sub(r"=", "", token)
|
||||
self.get("/ci/authentication", version=2, data={"nonce": token})
|
||||
|
||||
self.get("/ci/info", version=2) # clothes washer
|
||||
@@ -244,8 +272,10 @@ class HCDevice:
|
||||
# we could validate that this matches our machine
|
||||
pass
|
||||
|
||||
elif resource == "/ro/descriptionChange" \
|
||||
or resource == "/ro/allDescriptionChanges":
|
||||
elif (
|
||||
resource == "/ro/descriptionChange"
|
||||
or resource == "/ro/allDescriptionChanges"
|
||||
):
|
||||
# we asked for these but don't know have to parse yet
|
||||
pass
|
||||
|
||||
@@ -253,9 +283,8 @@ class HCDevice:
|
||||
# we're already talking, so maybe we don't care?
|
||||
pass
|
||||
|
||||
elif resource == "/ro/allMandatoryValues" \
|
||||
or resource == "/ro/values":
|
||||
if 'data' in msg:
|
||||
elif resource == "/ro/allMandatoryValues" or resource == "/ro/values":
|
||||
if "data" in msg:
|
||||
values = self.parse_values(msg["data"])
|
||||
else:
|
||||
print(now(), self.name, f"received {msg}")
|
||||
|
||||
41
HCSocket.py
41
HCSocket.py
@@ -1,49 +1,53 @@
|
||||
# Create a websocket that wraps a connection to a
|
||||
# Bosh-Siemens Home Connect device
|
||||
import socket
|
||||
import ssl
|
||||
import sslpsk
|
||||
import websocket
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import io
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
from base64 import urlsafe_b64decode as base64url
|
||||
from datetime import datetime
|
||||
|
||||
import sslpsk
|
||||
import websocket
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Hash import HMAC, SHA256
|
||||
from Crypto.Random import get_random_bytes
|
||||
|
||||
|
||||
# Convience to compute an HMAC on a message
|
||||
def hmac(key, msg):
|
||||
mac = HMAC.new(key, msg=msg, digestmod=SHA256).digest()
|
||||
return mac
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||
|
||||
|
||||
# Monkey patch for sslpsk in pip using the old _sslobj
|
||||
def _sslobj(sock):
|
||||
if (3, 5) <= sys.version_info <= (3, 7):
|
||||
return sock._sslobj._sslobj
|
||||
else:
|
||||
return sock._sslobj
|
||||
|
||||
|
||||
sslpsk.sslpsk._sslobj = _sslobj
|
||||
|
||||
|
||||
class HCSocket:
|
||||
def __init__(self, host, psk64, iv64=None):
|
||||
self.host = host
|
||||
self.psk = base64url(psk64 + '===')
|
||||
self.psk = base64url(psk64 + "===")
|
||||
self.debug = False
|
||||
|
||||
if iv64:
|
||||
# an HTTP self-encrypted socket
|
||||
self.http = True
|
||||
self.iv = base64url(iv64 + '===')
|
||||
self.enckey = hmac(self.psk, b'ENC')
|
||||
self.mackey = hmac(self.psk, b'MAC')
|
||||
self.iv = base64url(iv64 + "===")
|
||||
self.enckey = hmac(self.psk, b"ENC")
|
||||
self.mackey = hmac(self.psk, b"MAC")
|
||||
self.port = 80
|
||||
self.uri = "ws://" + host + ":80/homeconnect"
|
||||
else:
|
||||
@@ -82,7 +86,7 @@ class HCSocket:
|
||||
their_hmac = buf[-16:]
|
||||
|
||||
# compute the expected hmac on the encrypted message
|
||||
our_hmac = self.hmac_msg(b'\x43' + self.last_rx_hmac, enc_msg)
|
||||
our_hmac = self.hmac_msg(b"\x43" + self.last_rx_hmac, enc_msg)
|
||||
|
||||
if their_hmac != our_hmac:
|
||||
print("HMAC failure", their_hmac.hex(), our_hmac.hex(), file=sys.stderr)
|
||||
@@ -103,13 +107,13 @@ class HCSocket:
|
||||
|
||||
def encrypt(self, clear_msg):
|
||||
# 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_len = 16 - (len(clear_msg) % 16)
|
||||
if pad_len == 1:
|
||||
pad_len += 16
|
||||
pad = b'\x00' + get_random_bytes(pad_len-2) + bytearray([pad_len])
|
||||
pad = b"\x00" + get_random_bytes(pad_len - 2) + bytearray([pad_len])
|
||||
|
||||
clear_msg = clear_msg + pad
|
||||
|
||||
@@ -119,7 +123,7 @@ class HCSocket:
|
||||
|
||||
# compute the hmac of the encrypted message, chaining the
|
||||
# hmac of the previous message plus direction 'E'
|
||||
self.last_tx_hmac = self.hmac_msg(b'\x45' + self.last_tx_hmac, enc_msg)
|
||||
self.last_tx_hmac = self.hmac_msg(b"\x45" + self.last_tx_hmac, enc_msg)
|
||||
|
||||
# append the new hmac to the message
|
||||
return enc_msg + self.last_tx_hmac
|
||||
@@ -133,19 +137,20 @@ class HCSocket:
|
||||
sock = sslpsk.wrap_socket(
|
||||
sock,
|
||||
ssl_version=ssl.PROTOCOL_TLSv1_2,
|
||||
ciphers = 'ECDHE-PSK-CHACHA20-POLY1305',
|
||||
ciphers="ECDHE-PSK-CHACHA20-POLY1305",
|
||||
psk=self.psk,
|
||||
)
|
||||
|
||||
print(now(), "CON:", self.uri)
|
||||
self.ws = websocket.WebSocket()
|
||||
self.ws.connect(self.uri,
|
||||
self.ws.connect(
|
||||
self.uri,
|
||||
socket=sock,
|
||||
origin="",
|
||||
)
|
||||
|
||||
def send(self, msg):
|
||||
buf = json.dumps(msg, separators=(',', ':') )
|
||||
buf = json.dumps(msg, separators=(",", ":"))
|
||||
# swap " for '
|
||||
buf = re.sub("'", '"', buf)
|
||||
if self.debug:
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
#
|
||||
|
||||
import sys
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
#####################
|
||||
@@ -16,6 +15,7 @@ import xml.etree.ElementTree as ET
|
||||
# list of UIDs
|
||||
#
|
||||
|
||||
|
||||
def parse_xml_list(codes, entries, enums):
|
||||
for el in entries:
|
||||
# not sure how to parse refCID and refDID
|
||||
@@ -24,7 +24,7 @@ def parse_xml_list(codes, entries, enums):
|
||||
if not uid in codes:
|
||||
print("UID", uid, " not known!", file=sys.stderr)
|
||||
|
||||
data = codes[uid];
|
||||
data = codes[uid]
|
||||
if "uid" in codes:
|
||||
print("UID", uid, " used twice?", data, file=sys.stderr)
|
||||
|
||||
@@ -41,11 +41,12 @@ def parse_xml_list(codes, entries, enums):
|
||||
|
||||
# codes[uid] = data
|
||||
|
||||
|
||||
def parse_machine_description(entries):
|
||||
description = {}
|
||||
|
||||
for el in entries:
|
||||
prefix, has_namespace, tag = el.tag.partition('}')
|
||||
prefix, has_namespace, tag = el.tag.partition("}")
|
||||
if tag != "pairableDeviceTypes":
|
||||
description[tag] = el.text
|
||||
|
||||
@@ -95,7 +96,6 @@ def xml2json(features_xml,description_xml):
|
||||
"values": values,
|
||||
}
|
||||
|
||||
|
||||
for i in range(4, 8):
|
||||
parse_xml_list(features, description[i], enums)
|
||||
|
||||
|
||||
106
hc-login
106
hc-login
@@ -3,28 +3,24 @@
|
||||
# https://github.com/openid/AppAuth-Android
|
||||
# A really nice walk through of how it works is:
|
||||
# https://auth0.com/docs/get-started/authentication-and-authorization-flow/call-your-api-using-the-authorization-code-flow-with-pkce
|
||||
import requests
|
||||
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
|
||||
from lxml import html
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
from time import time
|
||||
from base64 import b64decode as base64_decode
|
||||
from base64 import urlsafe_b64encode as base64url_encode
|
||||
from bs4 import BeautifulSoup
|
||||
from Crypto.Random import get_random_bytes
|
||||
from Crypto.Hash import SHA256
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
from zipfile import ZipFile
|
||||
from HCxml2json import xml2json
|
||||
|
||||
import logging
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from Crypto.Hash import SHA256
|
||||
from Crypto.Random import get_random_bytes
|
||||
|
||||
from HCxml2json import xml2json
|
||||
|
||||
# These two lines enable debugging at httplib level (requests->urllib3->http.client)
|
||||
# You will see the REQUEST, including HEADERS and DATA, and RESPONSE with HEADERS but without DATA.
|
||||
# The only thing missing will be the response.body which is not logged.
|
||||
import http.client as http_client
|
||||
# http_client.HTTPConnection.debuglevel = 1
|
||||
|
||||
# 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.propagate = True
|
||||
|
||||
|
||||
def debug(*args):
|
||||
print(*args, file=sys.stderr)
|
||||
|
||||
|
||||
email = sys.argv[1]
|
||||
password = sys.argv[2]
|
||||
|
||||
@@ -45,8 +43,8 @@ headers = {"User-Agent": "hc-login/1.0"}
|
||||
session = requests.Session()
|
||||
session.headers.update(headers)
|
||||
|
||||
base_url = 'https://api.home-connect.com/security/oauth/'
|
||||
asset_url = 'https://prod.reu.rest.homeconnectegw.com/'
|
||||
base_url = "https://api.home-connect.com/security/oauth/"
|
||||
asset_url = "https://prod.reu.rest.homeconnectegw.com/"
|
||||
|
||||
##############3
|
||||
#
|
||||
@@ -55,32 +53,49 @@ asset_url = 'https://prod.reu.rest.homeconnectegw.com/'
|
||||
# even after the singlekey detour.
|
||||
#
|
||||
# The app_id and scope are hardcoded in the application
|
||||
app_id = '9B75AC9EC512F36C84256AC47D813E2C1DD0D6520DF774B020E1E6E2EB29B1F3'
|
||||
scope = ["ReadAccount","Settings","IdentifyAppliance","Control","DeleteAppliance","WriteAppliance","ReadOrigApi","Monitor","WriteOrigApi","Images"]
|
||||
scope = ["ReadOrigApi",]
|
||||
app_id = "9B75AC9EC512F36C84256AC47D813E2C1DD0D6520DF774B020E1E6E2EB29B1F3"
|
||||
scope = [
|
||||
"ReadAccount",
|
||||
"Settings",
|
||||
"IdentifyAppliance",
|
||||
"Control",
|
||||
"DeleteAppliance",
|
||||
"WriteAppliance",
|
||||
"ReadOrigApi",
|
||||
"Monitor",
|
||||
"WriteOrigApi",
|
||||
"Images",
|
||||
]
|
||||
scope = [
|
||||
"ReadOrigApi",
|
||||
]
|
||||
|
||||
|
||||
def b64(b):
|
||||
return re.sub(r'=', '', base64url_encode(b).decode('UTF-8'))
|
||||
return re.sub(r"=", "", base64url_encode(b).decode("UTF-8"))
|
||||
|
||||
|
||||
def b64random(num):
|
||||
return b64(base64url_encode(get_random_bytes(num)))
|
||||
|
||||
|
||||
verifier = b64(get_random_bytes(32))
|
||||
|
||||
login_query = {
|
||||
"response_type": "code",
|
||||
"prompt": "login",
|
||||
"code_challenge": b64(SHA256.new(verifier.encode('UTF-8')).digest()),
|
||||
"code_challenge": b64(SHA256.new(verifier.encode("UTF-8")).digest()),
|
||||
"code_challenge_method": "S256",
|
||||
"client_id": app_id,
|
||||
"scope": ' '.join(scope),
|
||||
"scope": " ".join(scope),
|
||||
"nonce": b64random(16),
|
||||
"state": b64random(16),
|
||||
"redirect_uri": 'hcauth://auth/prod',
|
||||
"redirect_target": 'icore',
|
||||
"redirect_uri": "hcauth://auth/prod",
|
||||
"redirect_target": "icore",
|
||||
}
|
||||
|
||||
loginpage_url = base_url + 'authorize?' + urlencode(login_query)
|
||||
token_url = base_url + 'token'
|
||||
loginpage_url = base_url + "authorize?" + urlencode(login_query)
|
||||
token_url = base_url + "token"
|
||||
|
||||
debug(f"{loginpage_url=}")
|
||||
r = session.get(loginpage_url)
|
||||
@@ -102,8 +117,8 @@ debug("--------")
|
||||
|
||||
# now that we have a session id, contact the
|
||||
# single key host to start the new login flow
|
||||
singlekey_host = 'https://singlekey-id.com'
|
||||
login_url = singlekey_host + '/auth/en-us/log-in/'
|
||||
singlekey_host = "https://singlekey-id.com"
|
||||
login_url = singlekey_host + "/auth/en-us/log-in/"
|
||||
|
||||
preauth_url = singlekey_host + "/auth/connect/authorize"
|
||||
preauth_query = {
|
||||
@@ -145,19 +160,38 @@ session.headers.update(headers)
|
||||
|
||||
debug("--------")
|
||||
|
||||
soup = BeautifulSoup(r.text, 'html.parser')
|
||||
requestVerificationToken = soup.find('input', {'name': '__RequestVerificationToken'}).get('value')
|
||||
r = session.post(preauth_url, data={"UserIdentifierInput.EmailInput.StringValue": email, "__RequestVerificationToken": requestVerificationToken }, allow_redirects=False)
|
||||
soup = BeautifulSoup(r.text, "html.parser")
|
||||
requestVerificationToken = soup.find(
|
||||
"input", {"name": "__RequestVerificationToken"}
|
||||
).get("value")
|
||||
r = session.post(
|
||||
preauth_url,
|
||||
data={
|
||||
"UserIdentifierInput.EmailInput.StringValue": email,
|
||||
"__RequestVerificationToken": requestVerificationToken,
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
|
||||
password_url = r.headers['location']
|
||||
password_url = r.headers["location"]
|
||||
if not bool(urlparse(password_url).netloc):
|
||||
password_url = singlekey_host + password_url
|
||||
|
||||
r = session.get(password_url, allow_redirects=False)
|
||||
soup = BeautifulSoup(r.text, 'html.parser')
|
||||
requestVerificationToken = soup.find('input', {'name': '__RequestVerificationToken'}).get('value')
|
||||
soup = BeautifulSoup(r.text, "html.parser")
|
||||
requestVerificationToken = soup.find(
|
||||
"input", {"name": "__RequestVerificationToken"}
|
||||
).get("value")
|
||||
|
||||
r = session.post(password_url, data={"Password": password, "RememberMe": "false", "__RequestVerificationToken": requestVerificationToken }, allow_redirects=False)
|
||||
r = session.post(
|
||||
password_url,
|
||||
data={
|
||||
"Password": password,
|
||||
"RememberMe": "false",
|
||||
"__RequestVerificationToken": requestVerificationToken,
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
|
||||
if return_url.startswith("/"):
|
||||
return_url = singlekey_host + return_url
|
||||
@@ -182,8 +216,8 @@ grant_type = query.get("grant_type")[0] # "authorization_code"
|
||||
|
||||
debug(f"{code=} {grant_type=} {state=}")
|
||||
|
||||
auth_url = base_url + 'login'
|
||||
token_url = base_url + 'token'
|
||||
auth_url = base_url + "login"
|
||||
token_url = base_url + "token"
|
||||
|
||||
token_fields = {
|
||||
"grant_type": grant_type,
|
||||
@@ -201,7 +235,7 @@ if r.status_code != requests.codes.ok:
|
||||
print(r.headers, r.text)
|
||||
exit(1)
|
||||
|
||||
debug('--------- got token page ----------')
|
||||
debug("--------- got token page ----------")
|
||||
|
||||
token = json.loads(r.text)["access_token"]
|
||||
debug(f"Received access {token=}")
|
||||
|
||||
54
hc2mqtt
54
hc2mqtt
@@ -2,9 +2,9 @@
|
||||
# Contact Bosh-Siemens Home Connect devices
|
||||
# and connect their messages to the mqtt server
|
||||
import json
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import ssl
|
||||
from threading import Thread
|
||||
|
||||
import click
|
||||
@@ -14,6 +14,7 @@ import paho.mqtt.client as mqtt
|
||||
from HCDevice import HCDevice
|
||||
from HCSocket import HCSocket, now
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("-d", "--devices_file", default="config/devices.json")
|
||||
@click.option("-h", "--mqtt_host", default="localhost")
|
||||
@@ -27,11 +28,23 @@ from HCSocket import HCSocket, now
|
||||
@click.option("--mqtt_keyfile")
|
||||
@click.option("--mqtt_clientname", default="hcpy")
|
||||
@click_config_file.configuration_option()
|
||||
|
||||
def hc2mqtt(devices_file: str, mqtt_host: str, mqtt_prefix: str, mqtt_port: int, mqtt_username: str,
|
||||
mqtt_password: str, mqtt_ssl: bool, mqtt_cafile: str, mqtt_certfile: str, mqtt_keyfile: str, mqtt_clientname: str):
|
||||
click.echo(f"Hello {devices_file=} {mqtt_host=} {mqtt_prefix=} {mqtt_port=} {mqtt_username=} {mqtt_password=} "
|
||||
f"{mqtt_ssl=} {mqtt_cafile=} {mqtt_certfile=} {mqtt_keyfile=} {mqtt_clientname=}")
|
||||
def hc2mqtt(
|
||||
devices_file: str,
|
||||
mqtt_host: str,
|
||||
mqtt_prefix: str,
|
||||
mqtt_port: int,
|
||||
mqtt_username: str,
|
||||
mqtt_password: str,
|
||||
mqtt_ssl: bool,
|
||||
mqtt_cafile: str,
|
||||
mqtt_certfile: str,
|
||||
mqtt_keyfile: str,
|
||||
mqtt_clientname: str,
|
||||
):
|
||||
click.echo(
|
||||
f"Hello {devices_file=} {mqtt_host=} {mqtt_prefix=} {mqtt_port=} {mqtt_username=} {mqtt_password=} "
|
||||
f"{mqtt_ssl=} {mqtt_cafile=} {mqtt_certfile=} {mqtt_keyfile=} {mqtt_clientname=}"
|
||||
)
|
||||
|
||||
with open(devices_file, "r") as f:
|
||||
devices = json.load(f)
|
||||
@@ -43,7 +56,12 @@ def hc2mqtt(devices_file: str, mqtt_host: str, mqtt_prefix: str, mqtt_port: int,
|
||||
|
||||
if mqtt_ssl:
|
||||
if mqtt_cafile and mqtt_certfile and mqtt_keyfile:
|
||||
client.tls_set(ca_certs=mqtt_cafile, certfile=mqtt_certfile, keyfile=mqtt_keyfile, cert_reqs=ssl.CERT_REQUIRED)
|
||||
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)
|
||||
|
||||
@@ -57,6 +75,7 @@ def hc2mqtt(devices_file: str, mqtt_host: str, mqtt_prefix: str, mqtt_port: int,
|
||||
|
||||
client.loop_forever()
|
||||
|
||||
|
||||
# Map their value names to easier state names
|
||||
topics = {
|
||||
"InternalError": "Error",
|
||||
@@ -65,15 +84,16 @@ topics = {
|
||||
global dev
|
||||
dev = {}
|
||||
|
||||
|
||||
def client_connect(client, device, mqtt_topic):
|
||||
def on_message(client, userdata, msg):
|
||||
print(msg.topic)
|
||||
mqtt_state = msg.payload.decode()
|
||||
mqtt_topic = msg.topic.split('/')
|
||||
mqtt_topic = msg.topic.split("/")
|
||||
print(now(), f"received mqtt message {mqtt_state}")
|
||||
try:
|
||||
msg = json.loads(mqtt_state)
|
||||
if 'uid' in msg:
|
||||
if "uid" in msg:
|
||||
dev[mqtt_topic[-2]].get("/ro/values", 1, "POST", msg)
|
||||
else:
|
||||
raise Exception(f"Payload {msg} is not correctly formatted")
|
||||
@@ -84,10 +104,15 @@ def client_connect(client, device, mqtt_topic):
|
||||
device_topics = topics
|
||||
|
||||
for value in device["features"]:
|
||||
if "access" in device["features"][value] and "read" in device["features"][value]['access'].lower():
|
||||
name = device["features"][value]['name'].split(".")
|
||||
if (
|
||||
"access" in device["features"][value]
|
||||
and "read" in device["features"][value]["access"].lower()
|
||||
):
|
||||
name = device["features"][value]["name"].split(".")
|
||||
device_topics[name[-1]] = name[-1]
|
||||
device_topics[value] = name[-1] #sometimes the returned key is a digit, making translation possible
|
||||
device_topics[value] = name[
|
||||
-1
|
||||
] # sometimes the returned key is a digit, making translation possible
|
||||
|
||||
state = {}
|
||||
for topic in device_topics:
|
||||
@@ -103,7 +128,9 @@ def client_connect(client, device, mqtt_topic):
|
||||
try:
|
||||
print(now(), device["name"], f"connecting to {host}")
|
||||
ws = HCSocket(host, device["key"], device.get("iv", None))
|
||||
dev[device["name"]] = HCDevice(ws, device.get("features", None), device["name"])
|
||||
dev[device["name"]] = HCDevice(
|
||||
ws, device.get("features", None), device["name"]
|
||||
)
|
||||
|
||||
# ws.debug = True
|
||||
ws.reconnect()
|
||||
@@ -142,5 +169,6 @@ def client_connect(client, device, mqtt_topic):
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
hc2mqtt()
|
||||
|
||||
Reference in New Issue
Block a user