453 lines
16 KiB
Python
Executable File
453 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# Parse messages from a Home Connect websocket (HCSocket)
|
|
# and keep the connection alive
|
|
#
|
|
# Possible resources to fetch from the devices:
|
|
#
|
|
# /ro/values
|
|
# /ro/descriptionChange
|
|
# /ro/allMandatoryValues
|
|
# /ro/allDescriptionChanges
|
|
# /ro/activeProgram
|
|
# /ro/selectedProgram
|
|
#
|
|
# /ei/initialValues
|
|
# /ei/deviceReady
|
|
#
|
|
# /ci/services
|
|
# /ci/registeredDevices
|
|
# /ci/pairableDevices
|
|
# /ci/delregistration
|
|
# /ci/networkDetails
|
|
# /ci/networkDetails2
|
|
# /ci/wifiNetworks
|
|
# /ci/wifiSetting
|
|
# /ci/wifiSetting2
|
|
# /ci/tzInfo
|
|
# /ci/authentication
|
|
# /ci/register
|
|
# /ci/deregister
|
|
#
|
|
# /ce/serverDeviceType
|
|
# /ce/serverCredential
|
|
# /ce/clientCredential
|
|
# /ce/hubInformation
|
|
# /ce/hubConnected
|
|
# /ce/status
|
|
#
|
|
# /ni/config
|
|
# /ni/info
|
|
#
|
|
# /iz/services
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
import threading
|
|
import time
|
|
import traceback
|
|
from base64 import urlsafe_b64encode as base64url_encode
|
|
from datetime import datetime
|
|
|
|
from Crypto.Random import get_random_bytes
|
|
|
|
|
|
def now():
|
|
return datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
|
|
|
|
|
|
class HCDevice:
|
|
def __init__(self, ws, device, debug=False):
|
|
self.ws = ws
|
|
self.features_lock = threading.Lock()
|
|
self.features = device.get("features")
|
|
self.name = device.get("name")
|
|
self.session_id = None
|
|
self.tx_msg_id = None
|
|
self.device_name = "hcpy"
|
|
self.device_id = "0badcafe"
|
|
self.debug = debug
|
|
self.services_initialized = False
|
|
self.services = {}
|
|
self.token = None
|
|
self.connected = False
|
|
|
|
def parse_values(self, values):
|
|
if not self.features:
|
|
return values
|
|
|
|
result = {}
|
|
|
|
for msg in values:
|
|
uid = str(msg["uid"])
|
|
value = msg["value"]
|
|
value_str = str(value)
|
|
|
|
name = uid
|
|
status = None
|
|
with self.features_lock:
|
|
if uid in self.features:
|
|
status = self.features[uid]
|
|
|
|
if status:
|
|
if "name" in status:
|
|
name = status["name"]
|
|
if "values" in status and value_str in status["values"]:
|
|
value = status["values"][value_str]
|
|
|
|
# trim everything off the name except the last part
|
|
name = re.sub(r"^.*\.", "", name)
|
|
result[name] = value
|
|
|
|
return result
|
|
|
|
# Based on PR submitted https://github.com/Skons/hcpy/pull/1
|
|
def test_program_data(self, data_array):
|
|
for data in data_array:
|
|
if "program" not in data:
|
|
raise TypeError("Message data invalid, no program specified.")
|
|
|
|
if isinstance(data["program"], int) is False:
|
|
raise TypeError("Message data invalid, UID in 'program' must be an integer.")
|
|
|
|
# devices.json stores UID as string
|
|
uid = str(data["program"])
|
|
with self.features_lock:
|
|
if uid not in self.features:
|
|
raise ValueError(
|
|
f"Unable to configure appliance. Program UID {uid} is not valid"
|
|
" for this device."
|
|
)
|
|
|
|
feature = self.features[uid]
|
|
# Diswasher is Dishcare.Dishwasher.Program.{name}
|
|
# Hood is Cooking.Common.Program.{name}
|
|
# May also be in the format BSH.Common.Program.Favorite.001
|
|
if "name" in feature:
|
|
if ".Program." not in feature["name"]:
|
|
raise ValueError(
|
|
f"Unable to configure appliance. Program UID {uid} is not a valid"
|
|
f" program - {feature['name']}."
|
|
)
|
|
else:
|
|
self.print(f"Unknown Program UID {uid}")
|
|
|
|
if "options" in data:
|
|
for option in data["options"]:
|
|
option_uid = option["uid"]
|
|
if str(option_uid) not in self.features:
|
|
raise ValueError(
|
|
f"Unable to configure appliance. Option UID {option_uid} is not"
|
|
" valid for this device."
|
|
)
|
|
|
|
# Test the feature of an appliance agains a data object
|
|
def test_feature(self, data_array):
|
|
for data in data_array:
|
|
if "uid" not in data:
|
|
raise Exception("Unable to configure appliance. UID is required.")
|
|
|
|
if isinstance(data["uid"], int) is False:
|
|
raise Exception("Unable to configure appliance. UID must be an integer.")
|
|
|
|
if "value" not in data:
|
|
raise Exception("Unable to configure appliance. Value is required.")
|
|
|
|
# Check if the uid is present for this appliance
|
|
uid = str(data["uid"])
|
|
with self.features_lock:
|
|
if uid not in self.features:
|
|
raise Exception(f"Unable to configure appliance. UID {uid} is not valid.")
|
|
|
|
feature = self.features[uid]
|
|
|
|
# check the access level of the feature
|
|
self.print(f"Processing feature {feature['name']} with uid {uid}")
|
|
if "access" not in feature:
|
|
raise Exception(
|
|
"Unable to configure appliance. "
|
|
f"Feature {feature['name']} with uid {uid} does not have access."
|
|
)
|
|
|
|
access = feature["access"].lower()
|
|
if access != "readwrite" and access != "writeonly":
|
|
raise Exception(
|
|
"Unable to configure appliance. "
|
|
f"Feature {feature['name']} with uid {uid} "
|
|
f"has got access {feature['access']}."
|
|
)
|
|
|
|
# check if selected list with values is allowed
|
|
if "values" in feature:
|
|
if isinstance(data["value"], int) is False:
|
|
raise Exception(
|
|
f"Unable to configure appliance. The value {data['value']} must "
|
|
f"be an integer. Allowed values are {feature['values']}."
|
|
)
|
|
|
|
value = str(data["value"])
|
|
# values are strings in the feature list,
|
|
# but always seem to be an integer. An integer must be provided
|
|
if value not in feature["values"]:
|
|
raise Exception(
|
|
"Unable to configure appliance. "
|
|
f"Value {data['value']} is not a valid value. "
|
|
f"Allowed values are {feature['values']}. "
|
|
)
|
|
|
|
if "min" in feature:
|
|
min = int(feature["min"])
|
|
max = int(feature["max"])
|
|
if (
|
|
isinstance(data["value"], int) is False
|
|
or data["value"] < min
|
|
or data["value"] > max
|
|
):
|
|
raise Exception(
|
|
"Unable to configure appliance. "
|
|
f"Value {data['value']} is not a valid value. "
|
|
f"The value must be an integer in the range {min} and {max}."
|
|
)
|
|
|
|
def recv(self):
|
|
try:
|
|
buf = self.ws.recv()
|
|
if buf is None:
|
|
return None
|
|
except Exception as e:
|
|
raise e
|
|
|
|
try:
|
|
return self.handle_message(buf)
|
|
except Exception as e:
|
|
self.print("error handling msg", e, buf, traceback.format_exc())
|
|
return None
|
|
|
|
# reply to a POST or GET message with new data
|
|
def reply(self, msg, reply):
|
|
self.ws.send(
|
|
{
|
|
"sID": msg["sID"],
|
|
"msgID": msg["msgID"], # same one they sent to us
|
|
"resource": msg["resource"],
|
|
"version": msg["version"],
|
|
"action": "RESPONSE",
|
|
"data": [reply],
|
|
}
|
|
)
|
|
|
|
# send a message to the device
|
|
def get(self, resource, version=1, action="GET", data=None):
|
|
if self.services_initialized:
|
|
resource_parts = resource.split("/")
|
|
if len(resource_parts) > 1:
|
|
service = resource.split("/")[1]
|
|
if service in self.services.keys():
|
|
version = self.services[service]["version"]
|
|
else:
|
|
self.print("ERROR service not known")
|
|
|
|
msg = {
|
|
"sID": self.session_id,
|
|
"msgID": self.tx_msg_id,
|
|
"resource": resource,
|
|
"version": version,
|
|
"action": action,
|
|
}
|
|
|
|
if data is not None:
|
|
if isinstance(data, list) is False:
|
|
data = [data]
|
|
|
|
if action == "POST" and self.debug is False:
|
|
if resource == "/ro/values":
|
|
# Raises exceptions on failure
|
|
self.test_feature(data)
|
|
elif resource == "/ro/activeProgram":
|
|
# Raises exception on failure
|
|
self.test_program_data(data)
|
|
elif resource == "/ro/selectedProgram":
|
|
# Raises exception on failure
|
|
self.test_program_data(data)
|
|
|
|
msg["data"] = data
|
|
|
|
try:
|
|
if self.debug:
|
|
self.print(f"TX: {msg}")
|
|
self.ws.send(msg)
|
|
except Exception as e:
|
|
print(self.name, "Failed to send", e, msg, traceback.format_exc())
|
|
self.tx_msg_id += 1
|
|
|
|
def reconnect(self):
|
|
# Receive initialization message /ei/initialValues
|
|
# Automatically responds in the handle_message function
|
|
|
|
# ask the device which services it supports
|
|
# registered devices gets pushed down too hence the loop
|
|
self.get("/ci/services")
|
|
while True:
|
|
time.sleep(1)
|
|
if self.services_initialized:
|
|
break
|
|
|
|
# We override the version based on the registered services received above
|
|
|
|
# the clothes washer wants this, the token doesn't matter,
|
|
# although they do not handle padding characters
|
|
# they send a response, not sure how to interpet it
|
|
self.token = base64url_encode(get_random_bytes(32)).decode("UTF-8")
|
|
self.token = re.sub(r"=", "", self.token)
|
|
self.get("/ci/authentication", version=2, data={"nonce": self.token})
|
|
|
|
self.get("/ci/info") # clothes washer
|
|
self.get("/iz/info") # dish washer
|
|
|
|
# Retrieves registered clients like phone/hcpy itself
|
|
# self.get("/ci/registeredDevices")
|
|
|
|
# tzInfo all returns empty?
|
|
# self.get("/ci/tzInfo")
|
|
|
|
# We need to send deviceReady for some devices or /ni/ will come back as 403 unauth
|
|
self.get("/ei/deviceReady", version=2, action="NOTIFY")
|
|
self.get("/ni/info")
|
|
# self.get("/ni/config", data={"interfaceID": 0})
|
|
|
|
# self.get("/ro/allDescriptionChanges")
|
|
self.get("/ro/allMandatoryValues")
|
|
self.get("/ro/values")
|
|
self.get("/ro/allDescriptionChanges")
|
|
|
|
def handle_message(self, buf):
|
|
msg = json.loads(buf)
|
|
if self.debug:
|
|
self.print("RX:", msg)
|
|
sys.stdout.flush()
|
|
|
|
resource = msg["resource"]
|
|
action = msg["action"]
|
|
|
|
values = {}
|
|
|
|
if "code" in msg:
|
|
values = {
|
|
"error": msg["code"],
|
|
"resource": msg.get("resource", ""),
|
|
}
|
|
elif action == "POST":
|
|
if resource == "/ei/initialValues":
|
|
# this is the first message they send to us and
|
|
# establishes our session plus message ids
|
|
self.session_id = msg["sID"]
|
|
self.tx_msg_id = msg["data"][0]["edMsgID"]
|
|
|
|
self.reply(
|
|
msg,
|
|
{
|
|
"deviceType": "Application",
|
|
"deviceName": self.device_name,
|
|
"deviceID": self.device_id,
|
|
},
|
|
)
|
|
|
|
threading.Thread(target=self.reconnect).start()
|
|
else:
|
|
self.print("Unknown resource", resource, file=sys.stderr)
|
|
|
|
elif action == "RESPONSE" or action == "NOTIFY":
|
|
if resource == "/iz/info" or resource == "/ci/info":
|
|
if "data" in msg and len(msg["data"]) > 0:
|
|
# Return Device Information such as Serial Number, SW Versions, MAC Address
|
|
values = msg["data"][0]
|
|
|
|
elif resource == "/ro/descriptionChange" or resource == "/ro/allDescriptionChanges":
|
|
if "data" in msg and len(msg["data"]) > 0:
|
|
with self.features_lock:
|
|
for change in msg["data"]:
|
|
uid = str(change["uid"])
|
|
if uid in self.features:
|
|
if "access" in change:
|
|
access = change["access"]
|
|
self.features[uid]["access"] = access
|
|
self.print(f"Access change for {uid} to {access}")
|
|
if "available" in change:
|
|
self.features[uid]["available"] = change["available"]
|
|
if "min" in change:
|
|
self.features[uid]["min"] = change["min"]
|
|
if "max" in change:
|
|
self.features[uid]["max"] = change["max"]
|
|
else:
|
|
# We wont have name for this item, so have to be careful
|
|
# when resolving elsewhere
|
|
self.features[uid] = change
|
|
|
|
elif resource == "/ni/info":
|
|
if "data" in msg and len(msg["data"]) > 0:
|
|
# Return Network Information/IP Address etc
|
|
values = msg["data"][0]
|
|
|
|
elif resource == "/ni/config":
|
|
# Returns some data about network interfaces e.g.
|
|
# [{'interfaceID': 0, 'automaticIPv4': True, 'automaticIPv6': True}]
|
|
pass
|
|
|
|
elif resource == "/ro/allMandatoryValues" or resource == "/ro/values":
|
|
if "data" in msg:
|
|
values = self.parse_values(msg["data"])
|
|
else:
|
|
self.print(f"received {msg}")
|
|
|
|
elif resource == "/ci/registeredDevices":
|
|
# This contains details of Phone/HCPY registered as clients to the device
|
|
pass
|
|
|
|
elif resource == "/ci/tzInfo":
|
|
pass
|
|
|
|
elif resource == "/ci/authentication":
|
|
if "data" in msg and len(msg["data"]) > 0:
|
|
# Grab authentication token - unsure if this is for us to use
|
|
# or to authenticate the server. Doesn't appear to be needed
|
|
self.token = msg["data"][0]["response"]
|
|
|
|
elif resource == "/ci/services":
|
|
for service in msg["data"]:
|
|
self.services[service["service"]] = {
|
|
"version": service["version"],
|
|
}
|
|
self.services_initialized = True
|
|
|
|
else:
|
|
self.print("Unknown response or notify:", msg)
|
|
|
|
else:
|
|
self.print("Unknown message", msg)
|
|
|
|
# return whatever we've parsed out of it
|
|
return values
|
|
|
|
def run_forever(self, on_message, on_open, on_close):
|
|
def _on_message(ws, message):
|
|
values = self.handle_message(message)
|
|
on_message(values)
|
|
|
|
def _on_open(ws):
|
|
self.connected = True
|
|
on_open(ws)
|
|
|
|
def _on_close(ws, code, message):
|
|
self.connected = False
|
|
on_close(ws, code, message)
|
|
|
|
def on_error(ws, message):
|
|
self.print("Websocket error:", message)
|
|
|
|
self.ws.run_forever(
|
|
on_message=_on_message, on_open=_on_open, on_close=_on_close, on_error=on_error
|
|
)
|
|
|
|
def print(self, *args):
|
|
print(now(), self.name, *args)
|