├── .gitignore ├── HCDevice.py ├── HCSocket.py ├── HCxml2json.py ├── LICENSE ├── README-frida.md ├── README.md ├── find-psk.frida ├── hc-login ├── hc2mqtt ├── images ├── clotheswasher.jpg ├── coffee.jpg ├── dishwasher.jpg ├── doorclose.jpg ├── kitchen.jpg └── network-setup.jpg └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | __* 3 | *.asc 4 | *.log 5 | *.bin 6 | *.key 7 | *.ws 8 | *.enc 9 | *.txt 10 | *.json 11 | *.zip 12 | *.sh 13 | -------------------------------------------------------------------------------- /HCDevice.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Parse messages from a Home Connect websocket (HCSocket) 3 | # and keep the connection alive 4 | # 5 | # Possible resources to fetch from the devices: 6 | # 7 | # /ro/values 8 | # /ro/descriptionChange 9 | # /ro/allMandatoryValues 10 | # /ro/allDescriptionChanges 11 | # /ro/activeProgram 12 | # /ro/selectedProgram 13 | # 14 | # /ei/initialValues 15 | # /ei/deviceReady 16 | # 17 | # /ci/services 18 | # /ci/registeredDevices 19 | # /ci/pairableDevices 20 | # /ci/delregistration 21 | # /ci/networkDetails 22 | # /ci/networkDetails2 23 | # /ci/wifiNetworks 24 | # /ci/wifiSetting 25 | # /ci/wifiSetting2 26 | # /ci/tzInfo 27 | # /ci/authentication 28 | # /ci/register 29 | # /ci/deregister 30 | # 31 | # /ce/serverDeviceType 32 | # /ce/serverCredential 33 | # /ce/clientCredential 34 | # /ce/hubInformation 35 | # /ce/hubConnected 36 | # /ce/status 37 | # 38 | # /ni/config 39 | # 40 | # /iz/services 41 | 42 | import sys 43 | import json 44 | import re 45 | import time 46 | import io 47 | import traceback 48 | from datetime import datetime 49 | from base64 import urlsafe_b64encode as base64url_encode 50 | from Crypto.Random import get_random_bytes 51 | 52 | 53 | def now(): 54 | return datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") 55 | 56 | class HCDevice: 57 | def __init__(self, ws, features): 58 | self.ws = ws 59 | self.features = features 60 | self.session_id = None 61 | self.tx_msg_id = None 62 | self.device_name = "hcpy" 63 | self.device_id = "0badcafe" 64 | self.debug = False 65 | 66 | def parse_values(self, values): 67 | if not self.features: 68 | return values 69 | 70 | result = {} 71 | 72 | for msg in values: 73 | uid = str(msg["uid"]) 74 | value = msg["value"] 75 | value_str = str(value) 76 | 77 | name = uid 78 | status = None 79 | 80 | if uid in self.features: 81 | status = self.features[uid] 82 | 83 | if status: 84 | name = status["name"] 85 | if "values" in status \ 86 | and value_str in status["values"]: 87 | value = status["values"][value_str] 88 | 89 | # trim everything off the name except the last part 90 | name = re.sub(r'^.*\.', '', name) 91 | result[name] = value 92 | 93 | return result 94 | 95 | def recv(self): 96 | try: 97 | buf = self.ws.recv() 98 | if buf is None: 99 | return None 100 | except Exception as e: 101 | print("receive error", e, traceback.format_exc()) 102 | return None 103 | 104 | try: 105 | return self.handle_message(buf) 106 | except Exception as e: 107 | print("error handling msg", e, buf, traceback.format_exc()) 108 | return None 109 | 110 | # reply to a POST or GET message with new data 111 | def reply(self, msg, reply): 112 | self.ws.send({ 113 | 'sID': msg["sID"], 114 | 'msgID': msg["msgID"], # same one they sent to us 115 | 'resource': msg["resource"], 116 | 'version': msg["version"], 117 | 'action': 'RESPONSE', 118 | 'data': [reply], 119 | }) 120 | 121 | # send a message to the device 122 | def get(self, resource, version=1, action="GET", data=None): 123 | msg = { 124 | "sID": self.session_id, 125 | "msgID": self.tx_msg_id, 126 | "resource": resource, 127 | "version": version, 128 | "action": action, 129 | } 130 | 131 | if data is not None: 132 | msg["data"] = [data] 133 | 134 | self.ws.send(msg) 135 | self.tx_msg_id += 1 136 | 137 | def handle_message(self, buf): 138 | msg = json.loads(buf) 139 | if self.debug: 140 | print(now(), "RX:", msg) 141 | sys.stdout.flush() 142 | 143 | 144 | resource = msg["resource"] 145 | action = msg["action"] 146 | 147 | values = {} 148 | 149 | if "code" in msg: 150 | #print(now(), "ERROR", msg["code"]) 151 | values = { 152 | "error": msg["code"], 153 | "resource": msg.get("resource", ''), 154 | } 155 | elif action == "POST": 156 | if resource == "/ei/initialValues": 157 | # this is the first message they send to us and 158 | # establishes our session plus message ids 159 | self.session_id = msg["sID"] 160 | self.tx_msg_id = msg["data"][0]["edMsgID"] 161 | 162 | self.reply(msg, { 163 | "deviceType": "Application", 164 | "deviceName": self.device_name, 165 | "deviceID": self.device_id, 166 | }) 167 | 168 | # ask the device which services it supports 169 | self.get("/ci/services") 170 | 171 | # the clothes washer wants this, the token doesn't matter, 172 | # although they do not handle padding characters 173 | # they send a response, not sure how to interpet it 174 | token = base64url_encode(get_random_bytes(32)).decode('UTF-8') 175 | token = re.sub(r'=', '', token) 176 | self.get("/ci/authentication", version=2, data={"nonce": token}) 177 | 178 | self.get("/ci/info", version=2) # clothes washer 179 | self.get("/iz/info") # dish washer 180 | #self.get("/ci/tzInfo", version=2) 181 | self.get("/ni/info") 182 | #self.get("/ni/config", data={"interfaceID": 0}) 183 | self.get("/ei/deviceReady", version=2, action="NOTIFY") 184 | self.get("/ro/allDescriptionChanges") 185 | self.get("/ro/allDescriptionChanges") 186 | self.get("/ro/allMandatoryValues") 187 | #self.get("/ro/values") 188 | else: 189 | print(now(), "Unknown resource", resource, file=sys.stderr) 190 | 191 | elif action == "RESPONSE" or action == "NOTIFY": 192 | if resource == "/iz/info" or resource == "/ci/info": 193 | # we could validate that this matches our machine 194 | pass 195 | 196 | elif resource == "/ro/descriptionChange" \ 197 | or resource == "/ro/allDescriptionChanges": 198 | # we asked for these but don't know have to parse yet 199 | pass 200 | 201 | elif resource == "/ni/info": 202 | # we're already talking, so maybe we don't care? 203 | pass 204 | 205 | elif resource == "/ro/allMandatoryValues" \ 206 | or resource == "/ro/values": 207 | values = self.parse_values(msg["data"]) 208 | elif resource == "/ci/registeredDevices": 209 | # we don't care 210 | pass 211 | 212 | elif resource == "/ci/services": 213 | self.services = {} 214 | for service in msg["data"]: 215 | self.services[service["service"]] = { 216 | "version": service["version"], 217 | } 218 | #print(now(), "services", self.services) 219 | 220 | # we should figure out which ones to query now 221 | # if "iz" in self.services: 222 | # self.get("/iz/info", version=self.services["iz"]["version"]) 223 | # if "ni" in self.services: 224 | # self.get("/ni/info", version=self.services["ni"]["version"]) 225 | # if "ei" in self.services: 226 | # self.get("/ei/deviceReady", version=self.services["ei"]["version"], action="NOTIFY") 227 | 228 | #self.get("/if/info") 229 | 230 | else: 231 | print(now(), "Unknown", msg) 232 | 233 | # return whatever we've parsed out of it 234 | return values 235 | -------------------------------------------------------------------------------- /HCSocket.py: -------------------------------------------------------------------------------- 1 | # Create a websocket that wraps a connection to a 2 | # Bosh-Siemens Home Connect device 3 | import socket 4 | import ssl 5 | import sslpsk 6 | import websocket 7 | import sys 8 | import json 9 | import re 10 | import time 11 | import io 12 | from base64 import urlsafe_b64decode as base64url 13 | from datetime import datetime 14 | from Crypto.Cipher import AES 15 | from Crypto.Hash import HMAC, SHA256 16 | from Crypto.Random import get_random_bytes 17 | 18 | # Convience to compute an HMAC on a message 19 | def hmac(key,msg): 20 | mac = HMAC.new(key, msg=msg, digestmod=SHA256).digest() 21 | return mac 22 | 23 | def now(): 24 | return datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") 25 | 26 | # Monkey patch for sslpsk in pip using the old _sslobj 27 | def _sslobj(sock): 28 | if (3, 5) <= sys.version_info <= (3, 7): 29 | return sock._sslobj._sslobj 30 | else: 31 | return sock._sslobj 32 | sslpsk.sslpsk._sslobj = _sslobj 33 | 34 | 35 | class HCSocket: 36 | def __init__(self, host, psk64, iv64=None): 37 | self.host = host 38 | self.psk = base64url(psk64 + '===') 39 | self.debug = False 40 | 41 | if iv64: 42 | # an HTTP self-encrypted socket 43 | self.http = True 44 | self.iv = base64url(iv64 + '===') 45 | self.enckey = hmac(self.psk, b'ENC') 46 | self.mackey = hmac(self.psk, b'MAC') 47 | self.port = 80 48 | self.uri = "ws://" + host + ":80/homeconnect" 49 | else: 50 | self.http = False 51 | self.port = 443 52 | self.uri = "wss://" + host + ":443/homeconnect" 53 | 54 | # don't connect automatically so that debug etc can be set 55 | #self.reconnect() 56 | 57 | # restore the encryption state for a fresh connection 58 | # this is only used by the HTTP connection 59 | def reset(self): 60 | if not self.http: 61 | return 62 | self.last_rx_hmac = bytes(16) 63 | self.last_tx_hmac = bytes(16) 64 | 65 | self.aes_encrypt = AES.new(self.enckey, AES.MODE_CBC, self.iv) 66 | self.aes_decrypt = AES.new(self.enckey, AES.MODE_CBC, self.iv) 67 | 68 | # hmac an inbound or outbound message, chaining the last hmac too 69 | def hmac_msg(self, direction, enc_msg): 70 | hmac_msg = self.iv + direction + enc_msg 71 | return hmac(self.mackey, hmac_msg)[0:16] 72 | 73 | def decrypt(self,buf): 74 | if len(buf) < 32: 75 | print("Short message?", buf.hex(), file=sys.stderr) 76 | return None 77 | if len(buf) % 16 != 0: 78 | print("Unaligned message? probably bad", buf.hex(), file=sys.stderr) 79 | 80 | # split the message into the encrypted message and the first 16-bytes of the HMAC 81 | enc_msg = buf[0:-16] 82 | their_hmac = buf[-16:] 83 | 84 | # compute the expected hmac on the encrypted message 85 | our_hmac = self.hmac_msg(b'\x43' + self.last_rx_hmac, enc_msg) 86 | 87 | if their_hmac != our_hmac: 88 | print("HMAC failure", their_hmac.hex(), our_hmac.hex(), file=sys.stderr) 89 | return None 90 | 91 | self.last_rx_hmac = their_hmac 92 | 93 | # decrypt the message with CBC, so the last message block is mixed in 94 | msg = self.aes_decrypt.decrypt(enc_msg) 95 | 96 | # check for padding and trim it off the end 97 | pad_len = msg[-1] 98 | if len(msg) < pad_len: 99 | print("padding error?", msg.hex()) 100 | return None 101 | 102 | return msg[0:-pad_len] 103 | 104 | def encrypt(self, clear_msg): 105 | # convert the UTF-8 string into a byte array 106 | clear_msg = bytes(clear_msg, 'utf-8') 107 | 108 | # pad the buffer, adding an extra block if necessary 109 | pad_len = 16 - (len(clear_msg) % 16) 110 | if pad_len == 1: 111 | pad_len += 16 112 | pad = b'\x00' + get_random_bytes(pad_len-2) + bytearray([pad_len]) 113 | 114 | clear_msg = clear_msg + pad 115 | 116 | # encrypt the padded message with CBC, so there is chained 117 | # state from the last cipher block sent 118 | enc_msg = self.aes_encrypt.encrypt(clear_msg) 119 | 120 | # compute the hmac of the encrypted message, chaining the 121 | # hmac of the previous message plus direction 'E' 122 | self.last_tx_hmac = self.hmac_msg(b'\x45' + self.last_tx_hmac, enc_msg) 123 | 124 | # append the new hmac to the message 125 | return enc_msg + self.last_tx_hmac 126 | 127 | def reconnect(self): 128 | self.reset() 129 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 130 | sock.connect((self.host,self.port)) 131 | 132 | if not self.http: 133 | sock = sslpsk.wrap_socket( 134 | sock, 135 | ssl_version = ssl.PROTOCOL_TLSv1_2, 136 | ciphers = 'ECDHE-PSK-CHACHA20-POLY1305', 137 | psk = self.psk, 138 | ) 139 | 140 | print(now(), "CON:", self.uri) 141 | self.ws = websocket.WebSocket() 142 | self.ws.connect(self.uri, 143 | socket = sock, 144 | origin = "", 145 | ) 146 | 147 | def send(self, msg): 148 | buf = json.dumps(msg, separators=(',', ':') ) 149 | # swap " for ' 150 | buf = re.sub("'", '"', buf) 151 | if self.debug: 152 | print(now(), "TX:", buf) 153 | if self.http: 154 | self.ws.send_binary(self.encrypt(buf)) 155 | else: 156 | self.ws.send(buf) 157 | 158 | def recv(self): 159 | buf = self.ws.recv() 160 | if buf is None or buf == "": 161 | return None 162 | 163 | if self.http: 164 | buf = self.decrypt(buf) 165 | if buf is None: 166 | return None 167 | 168 | if self.debug: 169 | print(now(), "RX:", buf) 170 | return buf 171 | -------------------------------------------------------------------------------- /HCxml2json.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Convert the featuremap and devicedescription XML files into a single JSON 3 | # this collapses the XML entities and duplicates some things, but makes for 4 | # easier parsing later 5 | # 6 | # Program groups are ignored for now 7 | # 8 | 9 | import sys 10 | import json 11 | import xml.etree.ElementTree as ET 12 | 13 | ##################### 14 | # 15 | # Parse the description file and collapse everything into a single 16 | # list of UIDs 17 | # 18 | 19 | def parse_xml_list(codes, entries, enums): 20 | for el in entries: 21 | # not sure how to parse refCID and refDID 22 | uid = int(el.attrib["uid"], 16) 23 | 24 | if not uid in codes: 25 | print("UID", uid, " not known!", file=sys.stderr) 26 | 27 | data = codes[uid]; 28 | if "uid" in codes: 29 | print("UID", uid, " used twice?", data, file=sys.stderr) 30 | 31 | for key in el.attrib: 32 | data[key] = el.attrib[key] 33 | 34 | # clean up later 35 | #del data["uid"] 36 | 37 | if "enumerationType" in el.attrib: 38 | del data["enumerationType"] 39 | enum_id = int(el.attrib["enumerationType"], 16) 40 | data["values"] = enums[enum_id]["values"] 41 | 42 | #codes[uid] = data 43 | 44 | def parse_machine_description(entries): 45 | description = {} 46 | 47 | for el in entries: 48 | prefix, has_namespace, tag = el.tag.partition('}') 49 | if tag != "pairableDeviceTypes": 50 | description[tag] = el.text 51 | 52 | return description 53 | 54 | 55 | def xml2json(features_xml,description_xml): 56 | # the feature file has features, errors, and enums 57 | # for now the ordering is hardcoded 58 | featuremapping = ET.fromstring(features_xml) #.getroot() 59 | description = ET.fromstring(description_xml) #.getroot() 60 | 61 | ##################### 62 | # 63 | # Parse the feature file 64 | # 65 | 66 | features = {} 67 | errors = {} 68 | enums = {} 69 | 70 | # Features are all possible UIDs 71 | for child in featuremapping[1]: #.iter('feature'): 72 | uid = int(child.attrib["refUID"], 16) 73 | name = child.text 74 | features[uid] = { 75 | "name": name, 76 | } 77 | 78 | # Errors 79 | for child in featuremapping[2]: 80 | uid = int(child.attrib["refEID"], 16) 81 | name = child.text 82 | errors[uid] = name 83 | 84 | # Enums 85 | for child in featuremapping[3]: 86 | uid = int(child.attrib["refENID"], 16) 87 | enum_name = child.attrib["enumKey"] 88 | values = {} 89 | for v in child: 90 | value = int(v.attrib["refValue"]) 91 | name = v.text 92 | values[value] = name 93 | enums[uid] = { 94 | "name": enum_name, 95 | "values": values, 96 | } 97 | 98 | 99 | for i in range(4,8): 100 | parse_xml_list(features, description[i], enums) 101 | 102 | # remove the duplicate uid field 103 | for uid in features: 104 | if "uid" in features[uid]: 105 | del features[uid]["uid"] 106 | 107 | return { 108 | "description": parse_machine_description(description[3]), 109 | "features": features, 110 | } 111 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2025 Trammell Hudson (osresearch) 4 | Portions Copyright (c) 2023 Eric Blade (ericblade) 5 | Portions Copyright (c) 2022 Jasper Seidel (jawsper) 6 | Portions Copyright (c) 2023 Thijs Kaper (atkaper) 7 | Portions Copyright (c) 2023 (meidlinga) 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all 17 | copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. 26 | -------------------------------------------------------------------------------- /README-frida.md: -------------------------------------------------------------------------------- 1 | This is for historical info; it is no longer necessary 2 | unless you're trying to make sense of the phone application 3 | or XML code. 4 | 5 | ## Finding the PSK and IV (no longer necessary) 6 | 7 | ![application setup screen](images/network-setup.jpg) 8 | 9 | You will need to set the dishwasher to "`Local network only`" 10 | in the setup application so that your phone will connect 11 | directly to it, rather than going through the cloud services. 12 | 13 | You'll also need a rooted Android phone running `frida-server` 14 | and the `find-psk.frida` script. This will hook the callback 15 | from the OpenSSL library `hcp::client_psk_callback` that is called 16 | when OpenSSL has made a connection and now needs to establish 17 | the PSK. 18 | 19 | ``` 20 | frida --no-pause -f com.bshg.homeconnect.android.release -U -l find-psk.frida 21 | ``` 22 | 23 | It should start the Home Connect application and eventually 24 | print a message like: 25 | 26 | ``` 27 | psk callback hint 'HCCOM_Local_App' 28 | psk 32 0x6ee63fb2f0 29 | 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF 30 | 00000000 0e c8 1f d8 c6 49 fa d8 bc e7 fd 34 33 54 13 d4 .....I.....43T.. 31 | 00000010 73 f9 2e 01 fc d8 26 80 49 89 4c 19 d7 2e cd cb s.....&.I.L..... 32 | ``` 33 | 34 | Which gives you the 32-byte PSK value to copy into the `hcpy` program. 35 | 36 | ## SSL logging 37 | 38 | The Frida script will also dump all of the SSL traffic so that you can 39 | see different endpoints and things. Not much is documented yet. 40 | 41 | Note that the TX from the phone on the websocket is "masked" with an 42 | repeating 4-byte XOR that is sent in the first part of each messages. 43 | The script could be augmented to decode those as well. 44 | The replies from the device are not masked so they can be read in the clear. 45 | 46 | ## Retrieving home appliance configuration 47 | 48 | ``` 49 | frida-trace -o initHomeAppliance.log -f "com.bshg.homeconnect.android.release" -U -j '*!initHomeAppliance'' 50 | ``` 51 | 52 | PSK can also be found in the last section of the config as base64url encoded. 53 | 54 | ``` 55 | echo 'Dsgf2MZJ-ti85_00M1QT1HP5LgH82CaASYlMGdcuzcs"' | tr '_\-"' '/+=' | base64 -d | xxd -g1 56 | ``` 57 | 58 | The IV is also there for devices that use it. This needs better documentation. 59 | 60 | TODO: document the other frida scripts that do `sendmsg()` and `Encrypt()` / `Decrypt()` tracing 61 | 62 | 63 | 64 | ## hcpy 65 | 66 | ![laptop in a dishwasher](images/laptop.jpg) 67 | 68 | The `hcpy` tool can contact your device, and if the PSK is correct, it will 69 | register for notification of events. 70 | 71 | ``` 72 | RX: {'sID': 2354590730, 'msgID': 3734589701, 'resource': '/ei/initialValues', 'version': 2, 'action': 'POST', 'data': [{'edMsgID': 3182729968}]} 73 | TX: {"sID":2354590730,"msgID":3734589701,"resource":"/ei/initialValues","version":2,"action":"RESPONSE","data":[{"deviceType":"Application","deviceName":"py-hca","deviceID":"1234"}]} 74 | TX: {"sID":2354590730,"msgID":3182729968,"resource":"/ci/services","version":1,"action":"GET"} 75 | TX: {"sID":2354590730,"msgID":3182729969,"resource":"/iz/info","version":1,"action":"GET"} 76 | TX: {"sID":2354590730,"msgID":3182729970,"resource":"/ei/deviceReady","version":2,"action":"NOTIFY"} 77 | RX: {'sID': 2354590730, 'msgID': 3182729968, 'resource': '/ci/services', 'version': 1, 'action': 'RESPONSE', 'data': [{'service': 'ci', 'version': 3}, {'service': 'ei', 'version': 2}, {'service': 'iz', 'version': 1}, {'service': 'ni', 'version': 1}, {'service': 'ro', 'version': 1}]} 78 | RX: {'sID': 2354590730, 'msgID': 3182729969, 'resource': '/iz/info', 'version': 1, 'action': 'RESPONSE', 'data': [{'deviceID': '....', 'eNumber': 'SX65EX56CN/11', 'brand': 'SIEMENS', 'vib': 'SX65EX56CN', 'mac': '....', 'haVersion': '1.4', 'swVersion': '3.2.10.20200911163726', 'hwVersion': '2.0.0.2', 'deviceType': 'Dishwasher', 'deviceInfo': '', 'customerIndex': '11', 'serialNumber': '....', 'fdString': '0201', 'shipSki': '....'}]} 79 | ``` 80 | 81 | ## Feature UID mapping 82 | 83 | There are other things that can be hooked in the application 84 | to get the mappings of the `uid` to actual menu settings and 85 | XML files of the configuration parameters. 86 | 87 | In the `xml/` directory are some of the device descriptions 88 | and feature maps that the app downloads from the Home Connect 89 | servers. Note that the XML has unadorned hex, while the 90 | websocket messages are in decimal. 91 | 92 | For instance, when the dishwasher door is closed and then 93 | re-opened, it sends the messages for `'uid':512`, which is 0x020F hex: 94 | 95 | ``` 96 | RX: {... 'data': [{'uid': 527, 'value': 1}]} 97 | RX: {... 'data': [{'uid': 527, 'value': 0}]} 98 | ``` 99 | 100 | In the `xml/dishwasher-description.xml` there is a `statusList` 101 | that says uid 0x020f is a readonly value that uses enum 0x0201: 102 | 103 | ``` 104 | 105 | ``` 106 | 107 | In the `xml/dishwasher-featuremap.xml` there is a mapping of feature 108 | reference UIDs to names: 109 | 110 | ``` 111 | BSH.Common.Status.DoorState 112 | ``` 113 | 114 | as well as mappings of enum ids to enum names and values: 115 | 116 | ``` 117 | 118 | Open 119 | Closed 120 | 121 | ``` 122 | 123 | 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![dishwasher installed in a kitchen](images/kitchen.jpg) 2 | 3 | # Interface with Home Connect appliances in Python 4 | 5 | This is a very, very beta interface for Bosch-Siemens Home Connect 6 | devices through their local network connection. Unlike most 7 | IoT devices that have a reputation for very bad security, BSG seem to have 8 | done a decent job of designing their system, especially since 9 | they allow a no-cloud local control configuration. The protocols 10 | seem sound, use well tested cryptographic libraries (TLS PSK with 11 | modern ciphres) or well understood primitives (AES-CBC with HMAC), 12 | and should prevent most any random attacker on your network from being able to 13 | [take over your appliances to mine cryptocurrency](http://www.antipope.org/charlie/blog-static/2013/12/trust-me.html). 14 | 15 | *WARNING: This tool not ready for prime time and is still beta!* 16 | 17 | ## Setup 18 | 19 | To avoid running into issues later with your default python installs, it's recommended to use a py virtual env for doing this. Go to your desired test directory, and: 20 | ``` 21 | python3 -m venv venv 22 | source venv/bin/activate 23 | git clone https://github.com/osresearch/hcpy 24 | cd hcpy 25 | pip3 install -r requirements.txt 26 | ``` 27 | 28 | Install the Python dependencies; the `sslpsk` one is a little weird 29 | and we might need to revisit it later. 30 | 31 | 32 | ### For Mac Users 33 | Installing `sslpsk` needs some extra steps: 34 | 35 | 1. The openssl package installed via brew: `brew install openssl`, and 36 | 1. Install `saslpsk` separately with flags: `LDFLAGS="-L$(brew --prefix openssl)/lib" CFLAGS="-I$(brew --prefix openssl)/include" pip3 install sslpsk` 37 | 1. Rest of the requirements should be fine with `pip3 install -r requirements.txt` 38 | 39 | ## Authenticate to the cloud servers 40 | 41 | ![laptop in a clothes washer with a display DoorState:Closed](images/doorclose.jpg) 42 | 43 | ```bash 44 | hc-login $USERNAME $PASSWORD > config.json 45 | ``` 46 | 47 | The `hc-login` script perfoms the OAuth process to login to your 48 | Home Connect account with your usename and password. It 49 | receives a bearer token that can then be used to retrieves 50 | a list of all the connected devices, their authentication 51 | and encryption keys, and XML files that describe all of the 52 | features and options. 53 | 54 | This only needs to be done once or when you add new devices; 55 | the resulting configuration JSON file *should* be sufficient to 56 | connect to the devices on your local network, assuming that 57 | your mDNS or DNS server resolves the names correctly. 58 | 59 | ## Home Connect to MQTT 60 | 61 | ```bash 62 | hc2mqtt config.json 63 | ``` 64 | 65 | This tool will establish websockets to the local devices and 66 | transform their messages into MQTT JSON messages. The exact 67 | format is likely to change; it is currently a thin translation 68 | layer over the XML retrieved from cloud servers during the 69 | initial configuration. 70 | 71 | ### Dishwasher 72 | 73 | ![laptop in a dishwasher](images/dishwasher.jpg) 74 | 75 | The dishwasher has a local HTTPS port open, although attempting to connect to 76 | the HTTPS port with `curl` results in a cryptic protocol error 77 | due to the non-standard cipher selection, `ECDHE-PSK-CHACHA20-POLY1305`. 78 | PSK also requires that both sides agree on a symetric key, 79 | so a special hacked version of `sslpsk` is used to establish the 80 | connection and then hand control to the Python `websock-client` 81 | library. 82 | 83 | Example message published to `homeconnect/dishwasher`: 84 | 85 | ```json 86 | { 87 | "state": "Run", 88 | "door": "Closed", 89 | "remaining": "2:49", 90 | "power": true, 91 | "lowwaterpressure": false, 92 | "aquastop": false, 93 | "error": false, 94 | "remainingseconds": 10140 95 | } 96 | ``` 97 | 98 |
99 | Full state information 100 | 101 | ```json 102 | { 103 | 'AllowBackendConnection': False, 104 | 'BackendConnected': False, 105 | 'RemoteControlLevel': 'ManualRemoteStart', 106 | 'SoftwareUpdateAvailable': 'Off', 107 | 'ConfirmPermanentRemoteStart': 'Off', 108 | 'ActiveProgram': 0, 109 | 'SelectedProgram': 8192, 110 | 'RemoteControlStartAllowed': False, 111 | '520': '2022-02-21T16:48:54', 112 | 'RemoteControlActive': True, 113 | 'AquaStopOccured': 'Off', 114 | 'DoorState': 'Open', 115 | 'PowerState': 'Off', 116 | 'ProgramFinished': 'Off', 117 | 'ProgramProgress': 100, 118 | 'LowWaterPressure': 'Off', 119 | 'RemainingProgramTime': 0, 120 | 'ProgramAborted': 'Off', 121 | '547': False, 122 | 'RemainingProgramTimeIsEstimated': True, 123 | 'OperationState': 'Inactive', 124 | 'StartInRelative': 0, 125 | 'EnergyForecast': 82, 126 | 'WaterForecast': 70, 127 | 'ConnectLocalWiFi': 'Off', 128 | 'SoftwareUpdateTransactionID': 0, 129 | 'SoftwareDownloadAvailable': 'Off', 130 | 'SoftwareUpdateSuccessful': 'Off', 131 | 'ProgramPhase': 'Drying', 132 | 'SilenceOnDemandRemainingTime': 0, 133 | 'EcoDryActive': False, 134 | 'RinseAid': 'R04', 135 | 'SensitivityTurbidity': 'Standard', 136 | 'ExtraDry': False, 137 | 'HotWater': 'ColdWater', 138 | 'TimeLight': 'On', 139 | 'EcoAsDefault': 'LastProgram', 140 | 'SoundLevelSignal': 'Off', 141 | 'SoundLevelKey': 'Medium', 142 | 'WaterHardness': 'H04', 143 | 'DryingAssistantAllPrograms': 'AllPrograms', 144 | 'SilenceOnDemandDefaultTime': 1800, 145 | 'SpeedOnDemand': False, 146 | 'InternalError': 'Off', 147 | 'CheckFilterSystem': 'Off', 148 | 'DrainingNotPossible': 'Off', 149 | 'DrainPumpBlocked': 'Off', 150 | 'WaterheaterCalcified': 'Off', 151 | 'LowVoltage': 'Off', 152 | 'SaltLack': 'Off', 153 | 'RinseAidLack': 'Off', 154 | 'SaltNearlyEmpty': 'Off', 155 | 'RinseAidNearlyEmpty': 'Off', 156 | 'MachineCareReminder': 'Off', 157 | '5121': False, 158 | 'HalfLoad': False, 159 | 'IntensivZone': False, 160 | 'VarioSpeedPlus': False, 161 | '5131': False, 162 | '5134': True, 163 | 'SilenceOnDemand': False 164 | } 165 | ``` 166 | 167 |
168 | 169 | ### Clothes washer 170 | 171 | ![laptop in a clothes washer](images/clotheswasher.jpg) 172 | 173 | The clothes washer has a local HTTP port that also responds to websocket 174 | traffic, although the contents of the frames are AES-CBC encrypted with a key 175 | derived from `HMAC(PSK,"ENC")` and authenticated with SHA256-HMAC using another 176 | key derived from `HMAC(PSK,"MAC")`. The encrypted messages are send as 177 | binary data over the websocket (type 0x82). 178 | 179 | Example message published to `homeconnect/washer`: 180 | 181 | ```json 182 | { 183 | "state": "Ready", 184 | "door": "Closed", 185 | "remaining": "3:48", 186 | "power": true, 187 | "lowwaterpressure": false, 188 | "aquastop": false, 189 | "error": false, 190 | "remainingseconds": 13680 191 | } 192 | ``` 193 | 194 |
195 | Full state information 196 | 197 | ```json 198 | { 199 | 'BackendConnected': False, 200 | 'CustomerEnergyManagerPaired': False, 201 | 'CustomerServiceConnectionAllowed': False, 202 | 'DoorState': 'Open', 203 | 'FlexStart': 'Disabled', 204 | 'LocalControlActive': False, 205 | 'OperationState': 'Ready', 206 | 'RemoteControlActive': True, 207 | 'RemoteControlStartAllowed': False, 208 | 'WiFiSignalStrength': -50, 209 | 'LoadInformation': 0, 210 | 'AquaStopOccured': 'Off', 211 | 'CustomerServiceRequest': 'Off', 212 | 'LowWaterPressure': 'Off', 213 | 'ProgramFinished': 'Off', 214 | 'SoftwareUpdateAvailable': 'Off', 215 | 'WaterLevelTooHigh': 'Off', 216 | 'DoorNotLockable': 'Off', 217 | 'DoorNotUnlockable': 'Off', 218 | 'DoorOpen': 'Off', 219 | 'FatalErrorOccured': 'Off', 220 | 'FoamDetection': 'Off', 221 | 'DrumCleanReminder': 'Off', 222 | 'PumpError': 'Off', 223 | 'ReleaseRinseHoldPending': 'Off', 224 | 'EnergyForecast': 20, 225 | 'EstimatedTotalProgramTime': 13680, 226 | 'FinishInRelative': 13680, 227 | 'FlexFinishInRelative': 0, 228 | 'ProgramProgress': 0, 229 | 'RemainingProgramTime': 13680, 230 | 'RemainingProgramTimeIsEstimated': True, 231 | 'WaterForecast': 40, 232 | 'LoadRecommendation': 10000, 233 | 'ProcessPhase': 4, 234 | 'ReferToProgram': 0, 235 | 'LessIroning': False, 236 | 'Prewash': False, 237 | 'RinseHold': False, 238 | 'RinsePlus': 0, 239 | 'SilentWash': False, 240 | 'Soak': False, 241 | 'SpeedPerfect': False, 242 | 'SpinSpeed': 160, 243 | 'Stains': 0, 244 | 'Temperature': 254, 245 | 'WaterPlus': False, 246 | 'AllowBackendConnection': False, 247 | 'AllowEnergyManagement': False, 248 | 'AllowFlexStart': False, 249 | 'ChildLock': False, 250 | 'Language': 'En', 251 | 'PowerState': 'On', 252 | 'EndSignalVolume': 'Medium', 253 | 'KeySignalVolume': 'Loud', 254 | 'EnableDrumCleanReminder': True, 255 | 'ActiveProgram': 0, 256 | 'SelectedProgram': 28718 257 | } 258 | ``` 259 | 260 |
261 | 262 | ### Coffee Machine 263 | 264 | ![Image of the coffee machine from the Siemens website](images/coffee.jpg) 265 | 266 | The coffee machine needs a better mapping to MQTT messages. 267 | 268 |
269 | Full state information 270 | 271 | ```json 272 | { 273 | 'LastSelectedBeverage': 8217, 274 | 'LocalControlActive': False, 275 | 'PowerSupplyError': 'Off', 276 | 'DripTrayNotInserted': 'Off', 277 | 'DripTrayFull': 'Off', 278 | 'WaterFilterShouldBeChanged': 'Off', 279 | 'WaterTankEmpty': 'Off', 280 | 'WaterTankNearlyEmpty': 'Off', 281 | 'BrewingUnitIsMissing': 'Off', 282 | 'SelectedProgram': 0, 283 | 'MacchiatoPause': '5Sec', 284 | 'ActiveProgram': 0, 285 | 'BeverageCountdownWaterfilter': 48, 286 | 'BeverageCountdownCalcNClean': 153, 287 | 'RemoteControlStartAllowed': True, 288 | 'EmptyDripTray': 'Off', 289 | 'BeverageCountdownDescaling': 153, 290 | 'EmptyDripTrayRemoveContainer': 'Off', 291 | 'BeverageCounterRistrettoEspresso': 177, 292 | 'AllowBackendConnection': True, 293 | 'BeverageCounterHotWater': 37351, 294 | 'RemindForMilkAfter': 'Off', 295 | 'BeverageCounterFrothyMilk': 22, 296 | 'BeverageCounterCoffeeAndMilk': 1077, 297 | 'CustomerServiceRequest': 'Off', 298 | '4645': 0, 299 | 'CoffeeMilkOrder': 'FirstCoffee', 300 | 'BackendConnected': True, 301 | 'BeverageCounterCoffee': 21, 302 | 'Enjoy': 'Off', 303 | 'UserMode': 'Barista', 304 | 'PlaceEmptyGlassUnderOutlet': 'Off', 305 | 'WaterTankNotInserted': 'Off', 306 | 'PlaylistRunning': False, 307 | 'BeverageCounterPowderCoffee': 9, 308 | 'DemoModeActive': False, 309 | 'CleanBrewingUnit': 'Off', 310 | 'WaterHardness': 'Medium', 311 | 'CloseDoor': 'Off', 312 | 'EmptyMilkTank': 'Off', 313 | 'SpecialRinsing': 'Off', 314 | 'AllowConsumerInsights': False, 315 | 'SwitchOffAfter': '01Hours15Minutes', 316 | '4681': 0, 317 | 'LastSelectedCoffeeWorldBeverage': 20514, 318 | 'BrightnessDisplay': 7, 319 | 'CleanMilkTank': 'Off', 320 | 'NotEnoughWaterForThisKindOfBeverage': 'Off', 321 | 'ChildLock': False, 322 | '4666': 0, 323 | 'Language': 'De', 324 | 'MilkContainerConnected': 'Off', 325 | 'SoftwareUpdateAvailable': 'Off', 326 | 'LeaveProfilesAutomatically': True, 327 | 'RemoveWaterFilter': 'Off', 328 | 'OperationState': 'Inactive', 329 | 'BeverageCounterHotMilk': 9, 330 | '4362': 0, 331 | 'MilkTubeRemoved': 'Off', 332 | 'DeviceIsToCold4C': 'Off', 333 | 'SystemHasRunDry': 'Off', 334 | 'DeviceShouldBeDescaled': 'Off', 335 | 'PowerState': 'Standby', 336 | 'DeviceShouldBeCleaned': 'Off', 337 | 'DeviceShouldBeCalcNCleaned': 'Off', 338 | 'BeanContainerEmpty': 'Off', 339 | 'MilkStillOK': 'Off', 340 | 'CoffeeOutletMissing': 'Off', 341 | 'MilkReminder': 'Off', 342 | 'RefillEmptyWaterTank': 'Off', 343 | 'RefillEmptyBeanContainer': 'Off', 344 | 'UnderOverVoltage': 'Off', 345 | 'NotEnoughPomaceCapacityForThisKindOfBeverage': 'Off', 346 | 'AdjustGrindSetting': 'Off', 347 | 'InsertWaterFilter': 'Off', 348 | 'FillDescaler': 'Off', 349 | 'CleanFillWaterTank': 'Off', 350 | 'PlaceContainerUnderOutlet': 'Off', 351 | 'SwitchOffPower30sekBackOn': 'Off', 352 | 'ThrowCleaningDiscInTheDrawer': 'Off', 353 | 'RemoveMilkContainer': 'Off', 354 | 'RemoveContainerUnderOutlet': 'Off', 355 | 'MilkContainerRemoved': 'Off', 356 | 'ServiceProgramFinished': 'Off', 357 | 'DeviceDescalingOverdue': 'Off', 358 | 'DeviceDescalingBlockage': 'Off', 359 | 'CustomerServiceConnectionAllowed': False, 360 | 'BeverageCountdownCleaning': 38, 361 | 'ProcessPhase': 'None' 362 | } 363 | ``` 364 | 365 |
366 | 367 | ## FRIDA tools 368 | 369 | Moved to [`README-frida.md`](README-frida.md) 370 | -------------------------------------------------------------------------------- /find-psk.frida: -------------------------------------------------------------------------------- 1 | /* 2 | * Locate the TLS PSK used by the dishwasher as the password that 3 | * protects access over the network. 4 | * 5 | * Launch it with: 6 | * frida --no-pause -f com.bshg.homeconnect.android.release -U -l find-psk.frida 7 | * 8 | * This will also dump all of the cleartext SSL traffic before encryption 9 | * and after decryption. For websocket traffic from the server, this 10 | * will be in the clear, although to the server is masked with an XOR 11 | * value. 12 | * 13 | * Note that it has to delay the Interceptor attach calls until the 14 | * library has been dlopen()'ed by the application, which is why there 15 | * is a setTimeout(). 16 | * 17 | * TODO: Is the PSK deterministic or is it randomly generated and stored 18 | * in the cloud? 19 | * 20 | * TODO: XOR unmask the websock TX traffic 21 | */ 22 | 23 | 24 | setTimeout(() => { 25 | console.log("looking for libHCPService.so"); 26 | 27 | const SSL_get_servername = new NativeFunction( 28 | Module.getExportByName("libHCPService.so", "SSL_get_servername"), 29 | "pointer", 30 | ["pointer","int"] 31 | ); 32 | 33 | Interceptor.attach(Module.getExportByName("libHCPService.so", "SSL_read"), 34 | { 35 | onEnter(args) { 36 | this.ssl = args[0]; 37 | this.buf = args[1]; 38 | }, 39 | onLeave(retval) { 40 | const server_ptr = SSL_get_servername(this.ssl, 0); 41 | const server = server_ptr.readUtf8String(); 42 | 43 | retval |= 0; 44 | if (retval <= 0) 45 | return; 46 | 47 | console.log("RX", server); 48 | console.log(Memory.readByteArray(this.buf, retval)); 49 | }, 50 | }) 51 | 52 | Interceptor.attach(Module.getExportByName("libHCPService.so", "SSL_write"), 53 | { 54 | onEnter(args) { 55 | this.ssl = args[0]; 56 | const len = Number(args[2]); 57 | const server_ptr = SSL_get_servername(this.ssl, 0); 58 | const server = server_ptr.readUtf8String(); 59 | 60 | console.log("TX", server); 61 | console.log(Memory.readByteArray(args[1], len)); 62 | }, 63 | }) 64 | 65 | /* 66 | * hcp::client_psk_callback is called when OpenSSL has made a connection and 67 | * the server has offered a client hint. 68 | */ 69 | Interceptor.attach(Module.getExportByName("libHCPService.so", "_ZN3hcp19client_psk_callbackEP6ssl_stPKcPcjPhj"), 70 | { 71 | onEnter(args) { 72 | this.ssl = args[0]; 73 | this.identity = args[2]; 74 | this.psk_buf = args[4]; 75 | const hint = Memory.readUtf8String(args[1]); 76 | console.log("psk callback hint '" + hint + "'"); 77 | }, 78 | onLeave(len) { 79 | len |= 0; 80 | console.log("psk", len, this.psk_buf); 81 | const buf = Memory.readByteArray(this.psk_buf, len); 82 | console.log(buf); 83 | }, 84 | }) 85 | 86 | }, 1000) 87 | -------------------------------------------------------------------------------- /hc-login: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # This directly follows the OAuth login flow that is opaquely described 3 | # https://github.com/openid/AppAuth-Android 4 | # A really nice walk through of how it works is: 5 | # https://auth0.com/docs/get-started/authentication-and-authorization-flow/call-your-api-using-the-authorization-code-flow-with-pkce 6 | import requests 7 | from urllib.parse import urlparse, parse_qs, urlencode, urlunparse 8 | from lxml import html 9 | import io 10 | import re 11 | import sys 12 | import json 13 | from time import time 14 | from base64 import b64decode as base64_decode 15 | from base64 import urlsafe_b64encode as base64url_encode 16 | from bs4 import BeautifulSoup 17 | from Crypto.Random import get_random_bytes 18 | from Crypto.Hash import SHA256 19 | from zipfile import ZipFile 20 | from HCxml2json import xml2json 21 | 22 | import logging 23 | 24 | # These two lines enable debugging at httplib level (requests->urllib3->http.client) 25 | # You will see the REQUEST, including HEADERS and DATA, and RESPONSE with HEADERS but without DATA. 26 | # The only thing missing will be the response.body which is not logged. 27 | import http.client as http_client 28 | #http_client.HTTPConnection.debuglevel = 1 29 | 30 | # You must initialize logging, otherwise you'll not see debug output. 31 | #logging.basicConfig() 32 | #logging.getLogger().setLevel(logging.DEBUG) 33 | #requests_log = logging.getLogger("requests.packages.urllib3") 34 | #requests_log.setLevel(logging.DEBUG) 35 | #requests_log.propagate = True 36 | 37 | def debug(*args): 38 | print(*args, file=sys.stderr) 39 | 40 | email = sys.argv[1] 41 | password = sys.argv[2] 42 | 43 | headers = {"User-Agent": "hc-login/1.0"} 44 | 45 | session = requests.Session() 46 | session.headers.update(headers) 47 | 48 | base_url = 'https://api.home-connect.com/security/oauth/' 49 | asset_url = 'https://prod.reu.rest.homeconnectegw.com/' 50 | 51 | ##############3 52 | # 53 | # Start by fetching the old login page, which gives 54 | # us the verifier and challenge for getting the token, 55 | # even after the singlekey detour. 56 | # 57 | # The app_id and scope are hardcoded in the application 58 | app_id = '9B75AC9EC512F36C84256AC47D813E2C1DD0D6520DF774B020E1E6E2EB29B1F3' 59 | scope = ["ReadAccount","Settings","IdentifyAppliance","Control","DeleteAppliance","WriteAppliance","ReadOrigApi","Monitor","WriteOrigApi","Images"] 60 | scope = ["ReadOrigApi",] 61 | 62 | def b64(b): 63 | return re.sub(r'=', '', base64url_encode(b).decode('UTF-8')) 64 | def b64random(num): 65 | return b64(base64url_encode(get_random_bytes(num))) 66 | 67 | verifier = b64(get_random_bytes(32)) 68 | 69 | login_query = { 70 | "response_type": "code", 71 | "prompt": "login", 72 | "code_challenge": b64(SHA256.new(verifier.encode('UTF-8')).digest()), 73 | "code_challenge_method": "S256", 74 | "client_id": app_id, 75 | "scope": ' '.join(scope), 76 | "nonce": b64random(16), 77 | "state": b64random(16), 78 | "redirect_uri": 'hcauth://auth/prod', 79 | "redirect_target": 'icore', 80 | } 81 | 82 | loginpage_url = base_url + 'authorize?' + urlencode(login_query) 83 | token_url = base_url + 'token' 84 | 85 | debug(f"{loginpage_url=}") 86 | r = session.get(loginpage_url) 87 | if r.status_code != requests.codes.ok: 88 | print("error fetching login url!", loginpage_url, r.text, file=sys.stderr) 89 | exit(1) 90 | 91 | # get the session from the text 92 | if not (match := re.search(r'"sessionId" value="(.*?)"', r.text)): 93 | print("Unable to find session id in login page") 94 | exit(1) 95 | session_id = match[1] 96 | if not (match := re.search(r'"sessionData" value="(.*?)"', r.text)): 97 | print("Unable to find session data in login page") 98 | exit(1) 99 | session_data = match[1] 100 | 101 | debug("--------") 102 | 103 | # now that we have a session id, contact the 104 | # single key host to start the new login flow 105 | singlekey_host = 'https://singlekey-id.com' 106 | login_url = singlekey_host + '/auth/en-us/log-in/' 107 | 108 | preauth_url = singlekey_host + "/auth/connect/authorize" 109 | preauth_query = { 110 | "client_id": "11F75C04-21C2-4DA9-A623-228B54E9A256", 111 | "redirect_uri": "https://api.home-connect.com/security/oauth/redirect_target", 112 | "response_type": "code", 113 | "scope": "openid email profile offline_access homeconnect.general", 114 | "prompt": "login", 115 | "style_id": "bsh_hc_01", 116 | "state": '{"session_id":"' + session_id + '"}', # important: no spaces! 117 | } 118 | 119 | # fetch the preauth state to get the final callback url 120 | preauth_url += "?" + urlencode(preauth_query) 121 | 122 | # loop until we have the callback url 123 | while True: 124 | debug(f"next {preauth_url=}") 125 | r = session.get(preauth_url, allow_redirects=False) 126 | if r.status_code == 200: 127 | break 128 | if r.status_code > 300 and r.status_code < 400: 129 | preauth_url = r.headers["location"] 130 | # Make relative locations absolute 131 | if not bool(urlparse(preauth_url).netloc): 132 | preauth_url = singlekey_host + preauth_url 133 | continue 134 | print(f"2: {preauth_url=}: failed to fetch {r} {r.text}", file=sys.stderr) 135 | exit(1) 136 | 137 | # get the ReturnUrl from the response 138 | query = parse_qs(urlparse(preauth_url).query) 139 | return_url = query["returnUrl"][0] 140 | debug(f"{return_url=}") 141 | 142 | if "X-CSRF-FORM-TOKEN" in r.cookies: 143 | headers["RequestVerificationToken"] = r.cookies["X-CSRF-FORM-TOKEN"] 144 | session.headers.update(headers) 145 | 146 | debug("--------") 147 | 148 | soup = BeautifulSoup(r.text, 'html.parser') 149 | requestVerificationToken = soup.find('input', {'name': '__RequestVerificationToken'}).get('value') 150 | r = session.post(preauth_url, data={"UserIdentifierInput.EmailInput.StringValue": email, "__RequestVerificationToken": requestVerificationToken }, allow_redirects=False) 151 | 152 | password_url = r.headers['location'] 153 | if not bool(urlparse(password_url).netloc): 154 | password_url = singlekey_host + password_url 155 | 156 | r = session.get(password_url, allow_redirects=False) 157 | soup = BeautifulSoup(r.text, 'html.parser') 158 | requestVerificationToken = soup.find('input', {'name': '__RequestVerificationToken'}).get('value') 159 | 160 | r = session.post(password_url, data={"Password": password, "RememberMe": "false", "__RequestVerificationToken": requestVerificationToken }, allow_redirects=False) 161 | 162 | if return_url.startswith("/"): 163 | return_url = singlekey_host + return_url 164 | 165 | while True: 166 | r = session.get(return_url, allow_redirects=False) 167 | debug(f"{return_url=}, {r} {r.text}") 168 | if r.status_code != 302: 169 | break 170 | return_url = r.headers["location"] 171 | if return_url.startswith("hcauth://"): 172 | break 173 | debug(f"{return_url=}") 174 | 175 | debug("--------") 176 | 177 | url = urlparse(return_url) 178 | query = parse_qs(url.query) 179 | code = query.get("code")[0] 180 | state = query.get("state")[0] 181 | grant_type = query.get("grant_type")[0] # "authorization_code" 182 | 183 | debug(f"{code=} {grant_type=} {state=}") 184 | 185 | auth_url = base_url + 'login' 186 | token_url = base_url + 'token' 187 | 188 | token_fields = { 189 | "grant_type": grant_type, 190 | "client_id": app_id, 191 | "code_verifier": verifier, 192 | "code": code, 193 | "redirect_uri": login_query["redirect_uri"], 194 | } 195 | 196 | debug(f"{token_url=} {token_fields=}") 197 | 198 | r = requests.post(token_url, data=token_fields, allow_redirects=False) 199 | if r.status_code != requests.codes.ok: 200 | print("Bad code?", file=sys.stderr) 201 | print(r.headers, r.text) 202 | exit(1) 203 | 204 | debug('--------- got token page ----------') 205 | 206 | token = json.loads(r.text)["access_token"] 207 | debug(f"Received access {token=}") 208 | 209 | headers = { 210 | "Authorization": "Bearer " + token, 211 | } 212 | 213 | # now we can fetch the rest of the account info 214 | r = requests.get(asset_url + "account/details", headers=headers) 215 | if r.status_code != requests.codes.ok: 216 | print("unable to fetch account details", file=sys.stderr) 217 | print(r.headers, r.text) 218 | exit(1) 219 | 220 | #print(r.text) 221 | account = json.loads(r.text) 222 | configs = [] 223 | 224 | print(account, file=sys.stderr) 225 | 226 | for app in account["data"]["homeAppliances"]: 227 | app_brand = app["brand"] 228 | app_type = app["type"] 229 | app_id = app["identifier"] 230 | 231 | config = { 232 | "name": app_type.lower(), 233 | } 234 | 235 | configs.append(config) 236 | 237 | if "tls" in app: 238 | # fancy machine with TLS support 239 | config["host"] =app_brand + "-" + app_type + "-" + app_id 240 | config["key"] = app["tls"]["key"] 241 | else: 242 | # less fancy machine with HTTP support 243 | config["host"] = app_id 244 | config["key"] = app["aes"]["key"] 245 | config["iv"] = app["aes"]["iv"] 246 | 247 | # Fetch the XML zip file for this device 248 | app_url = asset_url + "api/iddf/v1/iddf/" + app_id 249 | print("fetching", app_url, file=sys.stderr) 250 | r = requests.get(app_url, headers=headers) 251 | if r.status_code != requests.codes.ok: 252 | print(app_id, ": unable to fetch machine description?") 253 | next 254 | 255 | # we now have a zip file with XML, let's unpack them 256 | content = r.content 257 | print(app_url + ": " + app_id + ".zip", file=sys.stderr) 258 | with open(app_id + ".zip", "wb") as f: 259 | f.write(content) 260 | z = ZipFile(io.BytesIO(content)) 261 | #print(z.infolist()) 262 | features = z.open(app_id + "_FeatureMapping.xml").read() 263 | description = z.open(app_id + "_DeviceDescription.xml").read() 264 | 265 | machine = xml2json(features, description) 266 | config["description"] = machine["description"] 267 | config["features"] = machine["features"] 268 | 269 | print(json.dumps(configs, indent=4)) 270 | -------------------------------------------------------------------------------- /hc2mqtt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Contact Bosh-Siemens Home Connect devices 3 | # and connect their messages to the mqtt server 4 | import json 5 | import sys 6 | import time 7 | from threading import Thread 8 | 9 | import click 10 | import paho.mqtt.client as mqtt 11 | 12 | from HCDevice import HCDevice 13 | from HCSocket import HCSocket, now 14 | 15 | 16 | @click.command() 17 | @click.argument("config_file") 18 | @click.option("-h", "--mqtt_host", default="localhost") 19 | @click.option("-p", "--mqtt_prefix", default="homeconnect/") 20 | def hc2mqtt(config_file: str, mqtt_host: str, mqtt_prefix: str): 21 | click.echo(f"Hello {config_file=} {mqtt_host=} {mqtt_prefix=}") 22 | 23 | with open(config_file, "r") as f: 24 | devices = json.load(f) 25 | 26 | client = mqtt.Client() 27 | client.connect(host=mqtt_host, port=1883, keepalive=70) 28 | 29 | for device in devices: 30 | mqtt_topic = mqtt_prefix + device["name"] 31 | thread = Thread(target=client_connect, args=(client, device, mqtt_topic)) 32 | thread.start() 33 | 34 | client.loop_forever() 35 | 36 | 37 | # Map their value names to easier state names 38 | topics = { 39 | "OperationState": "state", 40 | "DoorState": "door", 41 | "RemainingProgramTime": "remaining", 42 | "PowerState": "power", 43 | "LowWaterPressure": "lowwaterpressure", 44 | "AquaStopOccured": "aquastop", 45 | "InternalError": "error", 46 | "FatalErrorOccured": "error", 47 | } 48 | 49 | 50 | 51 | def client_connect(client, device, mqtt_topic): 52 | host = device["host"] 53 | 54 | state = {} 55 | for topic in topics: 56 | state[topics[topic]] = None 57 | 58 | while True: 59 | try: 60 | ws = HCSocket(host, device["key"], device.get("iv",None)) 61 | dev = HCDevice(ws, device.get("features", None)) 62 | 63 | #ws.debug = True 64 | ws.reconnect() 65 | 66 | while True: 67 | msg = dev.recv() 68 | if msg is None: 69 | break 70 | if len(msg) > 0: 71 | print(now(), msg) 72 | 73 | update = False 74 | for topic in topics: 75 | value = msg.get(topic, None) 76 | if value is None: 77 | continue 78 | 79 | # Convert "On" to True, "Off" to False 80 | if value == "On": 81 | value = True 82 | elif value == "Off": 83 | value = False 84 | 85 | new_topic = topics[topic] 86 | if new_topic == "remaining": 87 | state["remainingseconds"] = value 88 | value = "%d:%02d" % (value / 60 / 60, (value / 60) % 60) 89 | 90 | state[new_topic] = value 91 | update = True 92 | 93 | if not update: 94 | continue 95 | 96 | msg = json.dumps(state) 97 | print("publish", mqtt_topic, msg) 98 | client.publish(mqtt_topic + "/state", msg) 99 | 100 | except Exception as e: 101 | print("ERROR", host, e, file=sys.stderr) 102 | 103 | time.sleep(5) 104 | 105 | if __name__ == "__main__": 106 | hc2mqtt() 107 | -------------------------------------------------------------------------------- /images/clotheswasher.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osresearch/hcpy/c8570590b439f49efd8fae0a801595bf40d95ef8/images/clotheswasher.jpg -------------------------------------------------------------------------------- /images/coffee.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osresearch/hcpy/c8570590b439f49efd8fae0a801595bf40d95ef8/images/coffee.jpg -------------------------------------------------------------------------------- /images/dishwasher.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osresearch/hcpy/c8570590b439f49efd8fae0a801595bf40d95ef8/images/dishwasher.jpg -------------------------------------------------------------------------------- /images/doorclose.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osresearch/hcpy/c8570590b439f49efd8fae0a801595bf40d95ef8/images/doorclose.jpg -------------------------------------------------------------------------------- /images/kitchen.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osresearch/hcpy/c8570590b439f49efd8fae0a801595bf40d95ef8/images/kitchen.jpg -------------------------------------------------------------------------------- /images/network-setup.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osresearch/hcpy/c8570590b439f49efd8fae0a801595bf40d95ef8/images/network-setup.jpg -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bs4 2 | requests 3 | pycryptodome 4 | websocket-client 5 | sslpsk 6 | paho.mqtt 7 | lxml 8 | click 9 | requests --------------------------------------------------------------------------------