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 all 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
81 changes: 77 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,83 @@ 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
layer over the XML retrieved from cloud servers during the
initial configuration.
transform their messages into MQTT JSON messages.
You have to manually specify the MQTT settings "MQTT_CONF" in your generated config file (explanations see below).
For renaming of the features of your device you also manually have to add a json entry "hc2mqtt" within the device:

```
[
{
"name": "MQTT_CONF",
"host": "localhost",
"topic_prefix": "hc2mqtt/",
"port": 1883,
"username": "",
"password": ""
},
{
"name":yourDishwasherName
"host":...
"key":...
"description": {
"type": "Dishwasher",
...
},
"hc2mqtt": {
"publish_as_json": true,
"publish_as_mqtt": true,
"publish_homie_topics": true,
"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": {
...
}
]
```
First add the MQTT_CONF block (just copy paste and adapt).
If you want to use the [Homie MQTT Convention](https://homieiot.github.io/specification/)
for MQTT publishing and your e.g. smart home system can automatically detect Homie-devices,
you probably have to change ```"topic_prefix": "hc2mqtt/"``` to ```"topic_prefix": "homie/"``` for automatic detection.

Second, add the ```"hc2mqtt": {...}``` block within each of your devices. This block specifies which features are specified and how they are specified:
- ```"publish_as_json":``` This is a thin translation layer over the XML retrieved from cloud servers during the initial configuration. It publishs one
json object containing all features in tshe MQTT topic <topic_prefix>/<yourDeviceName>/state (Use this for backwards compatibility. Otherwise you probably want one or both of the other options)
- ```"publish_as_mqtt":``` This will publish the features as multiple MQTT topics, so that each feature has its own topic
- ```"publish_homie_topics":``` Specify, if additional topics shall be published according to the Homie MQTT Convention (only applicable with "publish_as_mqtt_topics" set to True)

Which features are actually published, are specified with ```"publish"``` and ```"publish_never"```. The following rule priority is defined:
- 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.

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).

Some Examples:
1) With ```"publish_as_json": true, "publish_as_mqtt": false``` the above example will only publish ``` '{"ProgramFinished": "Off", "DoorState": "Closed", "WaterForecast": 45, "ExtraDrySet": false, [...]}' to 'hc2mqtt/yourDishwasherName/state'```
2) With ```"publish_as_json": false, "publish_as_mqtt": true``` the above example will only publish ```'Off' to 'hc2mqtt/yourDishwasherName/ProgramFinished/value'``` and ```'Closed' to 'hc2mqtt/yourDishwasherName/DoorState/value'``` and ```'45' to 'hc2mqtt/yourDishwasherName/WaterForecast/value'``` and ```'False' to 'hc2mqtt/yourDishwasherName/ExtraDrySet/value'``` and so on for all features.
3) With ```"publish_as_json": true, "publish_as_mqtt": true``` the above example will publish both from example 1) and 2)
4) With additionally ```"publish_homie_topics": true``` the above example will also the "$-meta-data-topics" for homie like
```
'BSH.Common.Status.DoorState' to 'homie/yourDishwasherName/DoorState/$name'
'type' to 'hc2mqtt/yourDishwasherName/DoorState/$type'
'value' to 'hc2mqtt/yourDishwasherName/DoorState/$properties'
'DoorState' to 'hc2mqtt/yourDishwasherName/DoorState/value/$name'
'enum' to 'hc2mqtt/yourDishwasherName/DoorState/value/$datatype'
'Open,Closed' to 'hc2mqtt/yourDishwasherName/DoorState/value/$format'
```

### Dishwasher

Expand Down
Loading