├── .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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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
--------------------------------------------------------------------------------