Merge pull request #12 from Meatballs1/skonspr

Allows setting device values through MQTT /set endpoint
This commit is contained in:
pmagyar
2024-03-19 17:16:21 +01:00
committed by GitHub
5 changed files with 221 additions and 52 deletions

5
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"githubPullRequests.ignoredPullRequestBranches": [
"main"
]
}

View File

@@ -54,7 +54,7 @@ def now():
return datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
class HCDevice:
def __init__(self, ws, features):
def __init__(self, ws, features, name):
self.ws = ws
self.features = features
self.session_id = None
@@ -62,6 +62,7 @@ class HCDevice:
self.device_name = "hcpy"
self.device_id = "0badcafe"
self.debug = False
self.name = name
def parse_values(self, values):
if not self.features:
@@ -92,19 +93,62 @@ class HCDevice:
return result
# Test the feature of an appliance agains a data object
def test_feature(self, data):
if 'uid' not in data:
raise Exception("{self.name}. Unable to configure appliance. UID is required.")
if isinstance(data['uid'], int) is False:
raise Exception("{self.name}. Unable to configure appliance. UID must be an integer.")
if 'value' not in data:
raise Exception("{self.name}. Unable to configure appliance. Value is required.")
# Check if the uid is present for this appliance
uid = str(data['uid'])
if uid not in self.features:
raise Exception(f"{self.name}. Unable to configure appliance. UID {uid} is not valid.")
feature = self.features[uid]
# check the access level of the feature
print(now(), self.name, f"Processing feature {feature['name']} with uid {uid}")
if 'access' not in feature:
raise Exception(f"{self.name}. Unable to configure appliance. Feature {feature['name']} with uid {uid} does not have access.")
access = feature['access'].lower()
if access != 'readwrite' and access != 'writeonly':
raise Exception(f"{self.name}. Unable to configure appliance. Feature {feature['name']} with uid {uid} 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 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']}.")
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(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
def recv(self):
try:
buf = self.ws.recv()
if buf is None:
return None
except Exception as e:
print("receive error", e, traceback.format_exc())
print(self.name, "receive error", e, traceback.format_exc())
return None
try:
return self.handle_message(buf)
except Exception as e:
print("error handling msg", e, buf, traceback.format_exc())
print(self.name, "error handling msg", e, buf, traceback.format_exc())
return None
# reply to a POST or GET message with new data
@@ -129,25 +173,32 @@ class HCDevice:
}
if data is not None:
msg["data"] = [data]
if action == "POST":
if self.test_feature(data) is False:
return
msg["data"] = [data]
else:
msg["data"] = [data]
self.ws.send(msg)
try:
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 handle_message(self, buf):
msg = json.loads(buf)
if self.debug:
print(now(), "RX:", msg)
print(now(), self.name, "RX:", msg)
sys.stdout.flush()
resource = msg["resource"]
action = msg["action"]
values = {}
if "code" in msg:
#print(now(), "ERROR", msg["code"])
print(now(), self.name, "ERROR", msg["code"])
values = {
"error": msg["code"],
"resource": msg.get("resource", ''),
@@ -186,7 +237,7 @@ class HCDevice:
self.get("/ro/allMandatoryValues")
#self.get("/ro/values")
else:
print(now(), "Unknown resource", resource, file=sys.stderr)
print(now(), self.name, "Unknown resource", resource, file=sys.stderr)
elif action == "RESPONSE" or action == "NOTIFY":
if resource == "/iz/info" or resource == "/ci/info":
@@ -204,7 +255,10 @@ class HCDevice:
elif resource == "/ro/allMandatoryValues" \
or resource == "/ro/values":
values = self.parse_values(msg["data"])
if 'data' in msg:
values = self.parse_values(msg["data"])
else:
print(now(), self.name, f"received {msg}")
elif resource == "/ci/registeredDevices":
# we don't care
pass
@@ -215,7 +269,7 @@ class HCDevice:
self.services[service["service"]] = {
"version": service["version"],
}
#print(now(), "services", self.services)
#print(self.name, now(), "services", self.services)
# we should figure out which ones to query now
# if "iz" in self.services:
@@ -227,8 +281,8 @@ class HCDevice:
#self.get("/if/info")
else:
print(now(), "Unknown", msg)
else:
print(now(), self.name, "Unknown", msg)
# return whatever we've parsed out of it
return values

View File

@@ -17,7 +17,7 @@ and should prevent most any random attacker on your network from being able to
## Setup
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:
```
```bash
python3 -m venv venv
source venv/bin/activate
git clone https://github.com/osresearch/hcpy
@@ -169,7 +169,6 @@ Example message published to `homeconnect/dishwasher`:
'SilenceOnDemand': False
}
```
</details>
### Clothes washer
@@ -262,14 +261,13 @@ Example message published to `homeconnect/washer`:
'SelectedProgram': 28718
}
```
</details>
### Coffee Machine
![Image of the coffee machine from the Siemens website](images/coffee.jpg)
The coffee machine needs a better mapping to MQTT messages.
Example message published to `homeconnect/coffeemaker`:
<details>
<summary>Full state information</summary>
@@ -367,9 +365,70 @@ The coffee machine needs a better mapping to MQTT messages.
'ProcessPhase': 'None'
}
```
</details>
## Posting to the appliance
Whereas the reading of the status is very beta, this is very very alpha. There is some basic error handling, but don't expect that everything will work.
In your config file you can find items that contain `readWrite` or `writeOnly`, some of them contain values so you know what to provide, ie:
```json
"539": {
"name": "BSH.Common.Setting.PowerState",
"access": "readWrite",
"available": "true",
"refCID": "03",
"refDID": "80",
"values": {
"2": "On",
"3": "Standby"
}
},
```
With this information you can build the JSON object you can send over mqtt to change the power state
Topic: `homeconnect/[devicename]/set`, ie `homeconnect/coffeemaker/set`
Payload:
```json
{"uid":539,"value":2}
```
As for now, the results will be displayed by the script only, there is no response to an mqtt topic.
There are properties that do not require predefined values, debugging is required to see what is needed. Here are some of those values found through debugging:
Set the time:
```json
{"uid":520,"value":"2023-07-07T15:01:21"}
```
Synchronize with time server, `false` is disabled
```json
{"uid":547,"value":false}
```
## FRIDA tools
Moved to [`README-frida.md`](README-frida.md)
## Home assistant
For integration with Home Assistant, the following MQTT sensor can be used to create a read only sensor
```yaml
- unique_id: "coffee_machine"
name: "Coffee Machine"
state_topic: "homeconnect/coffeemaker/state"
value_template: "{{ value_json.PowerState }}"
json_attributes_topic: "homeconnect/coffeemaker/state"
json_attributes_template: "{{ value_json | tojson }}"
```
## Notes
- Sometimes when the device is off, there is the error `ERROR [ip] [Errno 113] No route to host`
- There is a lot more information available, like the status of a program that is currently active. This needs to be integrated if possible. For now only the values that relate to the `config.json` are published

13
changelog.md Normal file
View File

@@ -0,0 +1,13 @@
# Changelog
All notable changes to this project will be documented in this file.
## 2023.9.12.1
### Added
- Ability to configure MQTT clientname
### Changed
- There was a default set of values being published. Now the device publishes what is present as access read, or readWrite in the `config.json`
### Fixed
- MQTT was not always published to the correct topic

104
hc2mqtt
View File

@@ -4,6 +4,7 @@
import json
import sys
import time
import ssl
from threading import Thread
import click
@@ -12,81 +13,118 @@ import paho.mqtt.client as mqtt
from HCDevice import HCDevice
from HCSocket import HCSocket, now
@click.command()
@click.argument("config_file")
@click.option("-h", "--mqtt_host", default="localhost")
@click.option("-p", "--mqtt_prefix", default="homeconnect/")
def hc2mqtt(config_file: str, mqtt_host: str, mqtt_prefix: str):
click.echo(f"Hello {config_file=} {mqtt_host=} {mqtt_prefix=}")
@click.option("--mqtt_port", default=1883, type=int)
@click.option("--mqtt_username")
@click.option("--mqtt_password")
@click.option("--mqtt_ssl", is_flag=True)
@click.option("--mqtt_cafile")
@click.option("--mqtt_certfile")
@click.option("--mqtt_keyfile")
@click.option("--mqtt_clientname", default="hcpy")
def hc2mqtt(config_file: str, mqtt_host: str, mqtt_prefix: str, mqtt_port: int, mqtt_username: str,
mqtt_password: str, mqtt_ssl: bool, mqtt_cafile: str, mqtt_certfile: str, mqtt_keyfile: str, mqtt_clientname: str):
click.echo(f"Hello {config_file=} {mqtt_host=} {mqtt_prefix=} {mqtt_port=} {mqtt_username=} {mqtt_password=} "
f"{mqtt_ssl=} {mqtt_cafile=} {mqtt_certfile=} {mqtt_keyfile=} {mqtt_clientname=}")
with open(config_file, "r") as f:
devices = json.load(f)
client = mqtt.Client()
client.connect(host=mqtt_host, port=1883, keepalive=70)
client = mqtt.Client(mqtt_clientname)
if mqtt_username and mqtt_password:
client.username_pw_set(mqtt_username, mqtt_password)
if mqtt_ssl:
if mqtt_cafile and mqtt_certfile and mqtt_keyfile:
client.tls_set(ca_certs=mqtt_cafile, certfile=mqtt_certfile, keyfile=mqtt_keyfile, cert_reqs=ssl.CERT_REQUIRED)
else:
client.tls_set(cert_reqs=ssl.CERT_NONE)
client.connect(host=mqtt_host, port=mqtt_port, keepalive=70)
for device in devices:
mqtt_topic = mqtt_prefix + device["name"]
print(now(), f"topic: {mqtt_topic}")
thread = Thread(target=client_connect, args=(client, device, mqtt_topic))
thread.start()
client.loop_forever()
# Map their value names to easier state names
topics = {
"OperationState": "state",
"DoorState": "door",
"RemainingProgramTime": "remaining",
"PowerState": "power",
"LowWaterPressure": "lowwaterpressure",
"AquaStopOccured": "aquastop",
"InternalError": "error",
"FatalErrorOccured": "error",
"InternalError": "Error",
"FatalErrorOccured": "Error",
}
global dev
dev = {}
def client_connect(client, device, mqtt_topic):
def on_message(client, userdata, msg):
print(msg.topic)
mqtt_state = msg.payload.decode()
mqtt_topic = msg.topic.split('/')
print(now(), f"received mqtt message {mqtt_state}")
try:
msg = json.loads(mqtt_state)
if 'uid' in msg:
dev[mqtt_topic[-2]].get("/ro/values", 1, "POST", msg)
else:
raise Exception(f"Payload {msg} is not correctly formatted")
except Exception as e:
print("ERROR", e, file=sys.stderr)
host = device["host"]
device_topics = topics
for value in device["features"]:
if "access" in device["features"][value] and "read" in device["features"][value]['access'].lower():
name = device["features"][value]['name'].split(".")
device_topics[name[-1]] = name[-1]
device_topics[value] = name[-1] #sometimes the returned key is a digit, making translation possible
state = {}
for topic in topics:
state[topics[topic]] = None
for topic in device_topics:
if not topic.isdigit(): #We only want the named topics
state[device_topics[topic]] = None
mqtt_set_topic = mqtt_topic + "/set"
print(now(), device["name"], f"set topic: {mqtt_set_topic}")
client.subscribe(mqtt_set_topic)
client.on_message = on_message
while True:
try:
print(now(), device["name"], f"connecting to {host}")
ws = HCSocket(host, device["key"], device.get("iv",None))
dev = HCDevice(ws, device.get("features", None))
dev[device["name"]] = HCDevice(ws, device.get("features", None), device["name"])
#ws.debug = True
ws.reconnect()
while True:
msg = dev.recv()
msg = dev[device["name"]].recv()
if msg is None:
break
if len(msg) > 0:
print(now(), msg)
print(now(), device["name"], msg)
update = False
for topic in topics:
for topic in device_topics:
value = msg.get(topic, None)
if value is None:
continue
# Convert "On" to True, "Off" to False
if value == "On":
value = True
elif value == "Off":
value = False
new_topic = topics[topic]
if new_topic == "remaining":
state["remainingseconds"] = value
value = "%d:%02d" % (value / 60 / 60, (value / 60) % 60)
# new_topic = topics[topic]
# if new_topic == "remaining":
# state["remainingseconds"] = value
# value = "%d:%02d" % (value / 60 / 60, (value / 60) % 60)
new_topic = device_topics[topic]
state[new_topic] = value
update = True
@@ -94,7 +132,7 @@ def client_connect(client, device, mqtt_topic):
continue
msg = json.dumps(state)
print("publish", mqtt_topic, msg)
print(now(), device["name"], f"publish to {mqtt_topic} with {msg}")
client.publish(mqtt_topic + "/state", msg)
except Exception as e: