Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Homie mqtt protocoll integration #1

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 119 additions & 31 deletions HCDevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def now():
return datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")

class HCDevice:
def __init__(self, ws, features, name):
def __init__(self, ws, features, name, description):
self.ws = ws
self.features = features
self.session_id = None
Expand All @@ -63,6 +63,13 @@ def __init__(self, ws, features, name):
self.device_id = "0badcafe"
self.debug = False
self.name = name
self.description = description
self.uids = {} #mapping of uids to features
for uid in self.features:
feature = self.features[uid]
feature_name = feature["name"]
self.uids[feature_name] = int(uid)


def parse_values(self, values):
if not self.features:
Expand All @@ -75,30 +82,74 @@ def parse_values(self, values):
value = msg["value"]
value_str = str(value)

name = uid
# name = uid
status = None

if uid in self.features:
status = self.features[uid]
status = self.features[uid]

if status:
name = status["name"]
# 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
#keep communication to HCDevice uid based. Formatting to human-readable names in hc2mqtt
# # trim everything off the name except the last part
# name = re.sub(r'^.*\.', '', name)
# result[name] = value
result[uid] = value

return result

def get_featureUID(self, feature_name):
if feature_name not in self.uids:
raise Exception("'{}' unknown feature_name. No UID found.")
return self.uids[feature_name]


# Test the uid used for a program of an appliance against available programs
# and Setting "BSH.Common.Setting.RemoteControlLevel"
def test_and_reformat_program(self, data):
#example json data content: {"program":8196, "options":[{"uid":558,"value":0},{"uid":5123,"value":false},{"uid":5126,"value":false},{"uid":5127,"value":false}]} (thanks to @chris-mc1)
#TODO check on options

if 'program' not in data:
raise Exception("{self.name}. Unable to configure appliance. 'program' is required.")

if isinstance(data['program'], str) == True:
try:
data['program'] = int(data['program']) #try to transform into int
except Exception as e:
raise Exception("{self.name}. Unable to configure appliance. UID in 'program' must be an integer.")
elif isinstance(data['program'], int) == False:
raise Exception("{self.name}. Unable to configure appliance. UID in 'program' must be an integer.")

# Check if the uid is a valid program for this appliance
uid = str(data['program'])
if uid not in self.features:
raise Exception(f"{self.name}. Unable to configure appliance. UID {uid} is not valid.")
feature = self.features[uid]
if ".Program." not in feature['name']: #check is valid for dishwasher. TODO: check other devices
raise Exception(f"{self.name}. Unable to configure appliance. UID {uid} is not a valid program.")

if remoteControlStartAllowed is None or not remoteControlStartAllowed: #allow if none, if device has no remoteControlStartAllowed feature (or a different uid for it than used to detect remoteControlStartAllowed)
#since this is not watched by the device itself
raise Exception(f"{self.name}. Program not started. Remote access ist not activated on device. Check and change 'RemoteControlStartAllowed'.")

return data

# Test the feature of an appliance agains a data object
def test_feature(self, data):
def test_and_reformat_feature(self, data):
#example json data content: {'uid': 539, 'value': 2}
if 'uid' not in data:
raise Exception("{self.name}. Unable to configure appliance. UID is required.")

if isinstance(data['uid'], int) == False:
if isinstance(data['uid'], str) == True:
try:
data['uid'] = int(data['uid']) #try to transform into int
except Exception as e:
raise Exception("{self.name}. Unable to configure appliance. UID must be an integer.")
elif isinstance(data['uid'], int) == False:
raise Exception("{self.name}. Unable to configure appliance. UID must be an integer.")

if 'value' not in data:
Expand All @@ -122,19 +173,30 @@ def test_feature(self, data):

# check if selected list with values is allowed
if 'values' in feature:
if isinstance(data['value'], int) == False:
raise Exception(f"Unable to configure appliance. The value {data['value']} must be an integer. Allowed values are {feature['values']}.")
value = str(data['value']) # values are strings in the feature list, but always seem to be an integer. An integer must be provided
if value not in feature['values']:
raise Exception(f"{self.name}. Unable to configure appliance. Value {data['value']} is not a valid value. Allowed values are {feature['values']}.")
value = None
if isinstance(data['value'], int):
#in difference to the comment below it has to be an integer (at least for dishwasher. TODO: check other devices)
#value = str(data['value']) # values are strings in the feature list, but always seem to be an integer. An integer must be provided
value = data['value']
if str(value) not in feature['values']:
raise Exception(f"{self.name}. Unable to configure appliance. Value {data['value']} is not a valid value. Allowed values are {feature['values']}.")
elif isinstance(data['value'], str):
for option in feature['values']:
if feature['values'][option] == data['value']:
value = int(option)
break
if value is None:
raise Exception(f"Unable to configure appliance. The value {data['value']} must be an integer or a string . Allowed values are {feature['values']}.")
else:
data['value'] = value

if 'min' in feature:
min = int(feature['min'])
max = int(feature['min'])
if isinstance(data['value'], int) == False or data['value'] < min or data['value'] > max:
raise Exception(f"{self.name}. Unable to configure appliance. Value {data['value']} is not a valid value. The value must be an integer in the range {min} and {max}.")

return True
return data

def recv(self):
try:
Expand All @@ -161,6 +223,7 @@ def reply(self, msg, reply):
'action': 'RESPONSE',
'data': [reply],
})


# send a message to the device
def get(self, resource, version=1, action="GET", data=None):
Expand All @@ -174,9 +237,18 @@ def get(self, resource, version=1, action="GET", data=None):

if data is not None:
if action == "POST":
if self.test_feature(data) != True:
return
msg["data"] = [data]
#if self.test_feature(data) != True:
# return
#msg["data"] = [data]
# print("REMINDER WIEDER test_and_reformat_feature AKTIVIEREN")
# msg["data"] = [data]
if resource == "/ro/activeProgram":
msg["data"] = [self.test_and_reformat_program(data)]
elif resource == "/ro/values":
msg["data"] = [self.test_and_reformat_feature(data)]
else:
print("Warning: for this resource no checks are performed on data")
msg["data"] = [data]
else:
msg["data"] = [data]

Expand All @@ -186,6 +258,11 @@ def get(self, resource, version=1, action="GET", data=None):
print(self.name, "Failed to send", e, msg, traceback.format_exc())
self.tx_msg_id += 1

# same like get, but with POST as action default
def post(self, resource, version=1, action="POST", data=None):
self.get(resource, version, action, data)


def handle_message(self, buf):
msg = json.loads(buf)
if self.debug:
Expand Down Expand Up @@ -219,23 +296,29 @@ def handle_message(self, buf):
# ask the device which services it supports
self.get("/ci/services")

# the clothes washer wants this, the token doesn't matter,
# although they do not handle padding characters
# they send a response, not sure how to interpet it
token = base64url_encode(get_random_bytes(32)).decode('UTF-8')
token = re.sub(r'=', '', token)
self.get("/ci/authentication", version=2, data={"nonce": token})

self.get("/ci/info", version=2) # clothes washer
self.get("/iz/info") # dish washer
if (self.description["type"] != "Dishwasher"): #TODO instead of != dishwasher change it to == clothwasehr - but to the name what it is actually(don't have one))
# the clothes washer wants this, the token doesn't matter,
# although they do not handle padding characters
# they send a response, not sure how to interpet it
token = base64url_encode(get_random_bytes(32)).decode('UTF-8')
token = re.sub(r'=', '', token)
self.get("/ci/authentication", version=2, data={"nonce": token})

if (self.description["type"] != "Dishwasher"): #TODO instead of != dishwasher change it to == clothwasehr - but to what it actually belongs
self.get("/ci/info", version=2) # clothes washer
if (self.description["type"] == "Dishwasher"):
self.get("/iz/info") # dish washer
#self.get("/ci/tzInfo", version=2)
self.get("/ni/info")
if (self.description["type"] != "Dishwasher"): #TODO instead of != dishwasher change it to == clothwasehr - but to what it actually belongs
self.get("/ni/info")
#self.get("/ni/config", data={"interfaceID": 0})
self.get("/ei/deviceReady", version=2, action="NOTIFY")
self.get("/ro/allDescriptionChanges")
#Note: allDescriptionChanges was twice. One commented out, since not necessary at least for dishwasher. Is it necessary for other devices?
#self.get("/ro/allDescriptionChanges")
self.get("/ro/allDescriptionChanges")
self.get("/ro/allMandatoryValues")
#self.get("/ro/values")
#self.get("/ro/values")
else:
print(now(), self.name, "Unknown resource", resource, file=sys.stderr)

Expand All @@ -258,7 +341,12 @@ def handle_message(self, buf):
if 'data' in msg:
values = self.parse_values(msg["data"])
else:
print(now(), self.name, f"received {msg}")
print(now(), self.name, f"received {action}: {msg}")

if '517' in values:#uid for BSH.Common.Status.RemoteControlStartAllowed (at least for dishwasher)
global remoteControlStartAllowed
remoteControlStartAllowed = values['517']

elif resource == "/ci/registeredDevices":
# we don't care
pass
Expand Down
45 changes: 43 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,52 @@ hc2mqtt config.json
```

This tool will establish websockets to the local devices and
transform their messages into MQTT JSON messages. The exact
format is likely to change; it is currently a thin translation
transform their messages into MQTT JSON messages.
Default for MQTT publishing is the [Homie MQTT Convention]{https://homieiot.github.io/specification/}.
This is useful for automatic detection in smart-home systems.
Additionally publishing as one whole json can be chosen as a thin translation
layer over the XML retrieved from cloud servers during the
initial configuration.

For renaming of the features you manually have to add a json entry "hc2mqtt" like this one inside your generated config file:

```
[
{
"name":yourDishwasherName
"host":...
"key":...
"description": {
"type": "Dishwasher",
...
},
"hc2mqtt": {
"rename": {
"default": "short",
"Dishcare.Dishwasher.Setting.ExtraDry": "ExtraDrySet"
},
"publish": {
"contains": ["BSH.Common.Event.","BSH.Common.Option.","BSH.Common.Root.","BSH.Common.Setting.","BSH.Common.Status.","Dishcare.Dishwasher.Event.","Dishcare.Dishwasher.Option.","Dishcare.Dishwasher.Status.","Dishcare.Dishwasher.Setting."],
"long_names": []
},
"publish_never": {
"contains": [],
"long_names": []
}
},
"features": {
...
}
]
```
In "rename" you can define the default naming behaviour for MQTT exposing ("short" ( = last part of the name), "long" or "uid") or specify explicit renaming for some features (overwrites default setting).
In "publish" and "publish never" you can specify which features shall be or shall not be published to MQTT. With following priority:
- feature name is exactly specified in long_names (individual names of features) in "publish" => publish
- if not: long_names specified in "publish_never" => do not publish
- if not: "publish_never" contains a substring of the feature name => publish not
- if not: "publish" contains a substring of the feature name => publish
- if not: => publish not.

### Dishwasher

![laptop in a dishwasher](images/dishwasher.jpg)
Expand Down
Loading