Skip to content

Commit

Permalink
Merge pull request #6 from ahochsteger/mqtt-support
Browse files Browse the repository at this point in the history
feat(shell): add mqtt + env config support
  • Loading branch information
joscha82 committed Apr 15, 2022
2 parents ed2f90e + e07d25e commit ae3e37c
Show file tree
Hide file tree
Showing 4 changed files with 1,779 additions and 809 deletions.
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ Wattpilot Shell Commands:
exit: Exit the shell
get <name>: Get a property value
info: Print most important infos
list: List all known property keys
list [propsearch]: List all properties (starting with propsearch if given)
mqtt-connect <host> <port>: Connect to MQTT server
mqtt-disconnect: Disconnect from MQTT server
set <name> <value>: Set a property value
watch message <type>: Watch message of given message type
watch property <name>: Watch value changes of given property name
Expand All @@ -42,3 +44,38 @@ python3 shell.py <wattpilot_ip> <password> "<command> <args...>"
python3 shell.py <wattpilot_ip> <password> "get amp"
python3 shell.py <wattpilot_ip> <password> "set amp 6"
```
## Environment Variables
| Environment Variable | Description | Default Value |
| -------------------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| MQTT_BASE_TOPIC | Base topic for MQTT | wattpilot |
| MQTT_CLIENT_ID | MQTT client ID | wattpilot2mqtt |
| MQTT_ENABLED | Enable MQTT | false |
| MQTT_HA_DISCOVERY_ENABLED | Enable Home Assistant Discovery (not yet implemented) | false |
| MQTT_HOST | MQTT host to connect to | |
| MQTT_MESSAGE_TOPIC_PATTERN | Topic pattern to publish Wattpilot messages to | {baseTopic}/{serialNumber}/messages/{messageType} |
| MQTT_PORT | Port of the MQTT host to connect to | 1883 |
| MQTT_PROPERTY_READ_TOPIC_PATTERN | Topic pattern to publish property values to | {baseTopic}/{serialNumber}/properties/{propName} |
| MQTT_PROPERTY_SET_TOPIC_PATTERN | Topic pattern to listen for property value changes for (not yet implemented) | {baseTopic}/{serialNumber}/properties/{propName}/set |
| MQTT_PUBLISH_MESSAGES | Publish received Wattpilot messages to MQTT | true |
| MQTT_PUBLISH_PROPERTIES | Publish received property values to MQTT | false |
| MQTT_WATCH_PROPERTIES | List of space-separated default properties to publish changes for (use "" for all properties) | amp car fna lmo sse |
| WATTPILOT_CONNECT_TIMEOUT | Connect timeout for Wattpilot connection | 30 |
| WATTPILOT_DEBUG_LEVEL | Debug level | INFO |
| WATTPILOT_INITIALIZED_TIMEOUT | Wait timeout for property initialization | 30 |
| WATTPILOT_HOST | IP address of the Wattpilot device to connect to | |
| WATTPILOT_PASSWORD | Password for connecting to the Wattpilot device | |
## MQTT Support
It is possible to publish JSON messages received from Wattpilot and/or individual property value changes to an MQTT server.
The easiest way to start the shell with MQTT support is using these environment variables:
```bash
export MQTT_ENABLED=true
export MQTT_HOST=<mqtt_host>
export WATTPILOT_HOST=<wattpilot_ip>
export WATTPILOT_PASSWORD=<wattpilot_password>
python3 shell.py
```
251 changes: 179 additions & 72 deletions shell.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import argparse
import json
import logging
import os
import paho.mqtt.client as mqtt
import readline
import wattpilot
import yaml
Expand All @@ -10,18 +12,23 @@

_LOGGER = logging.getLogger(__name__)

def printPropInfo(wpi, propName, value):
propInfo = wpi['properties'][propName]
def print_prop_info(wpi, propName, value):
#propInfo = next((x for x in wpi['properties'] if x['key'] == propName), None)
propInfo = wpi_properties[propName]
_LOGGER.debug(f"Property info: {propInfo}")
propTitle = ""
propDesc = ""
propAlias = ""
propRw = ""
if 'rw' in propInfo:
propRw = f", rw:{propInfo['rw']}"
if 'alias' in propInfo:
propAlias = f", alias:{propInfo['alias']}"
if 'title' in propInfo:
propTitle = propInfo['title']
if 'description' in propInfo:
propDesc = propInfo['description']
print(f"- {propName} ({propInfo['type']}{propAlias}): {propTitle}")
print(f"- {propName} ({propInfo['jsonType']}{propRw}{propAlias}): {propTitle}")
print(f" Value: {value}")
if propDesc:
print(f" Description: {propDesc}")
Expand All @@ -36,11 +43,92 @@ def printPropInfo(wpi, propName, value):

def watch_properties(name,value):
if name in watching_properties:
print(f"INFO: Property {name} changed to {value}")
_LOGGER.info(f"Property {name} changed to {value}")

def watch_messages(wsapp,msg):
def watch_messages(wp,wsapp,msg,msg_json):
if msg.type in watching_messages:
print(f"INFO: Message of type {msg.type} received: {msg}")
_LOGGER.info(f"Message of type {msg.type} received: {msg}")

def cmd_get(wp, args):
if len(args) != 1:
_LOGGER.error(f"Wrong number of arguments: get <property>")
elif not args[0] in wp.allProps:
_LOGGER.error(f"Unknown property: {args[0]}")
else:
print(wp.allProps[args[0]])

def cmd_mqtt_connect(wp, args):
if len(args) != 2:
_LOGGER.error(f"Wrong number of arguments: mqtt-connect <host> <port>")
else:
host = args[0]
port = int(args[1])
mqtt_client.connect(host, port)

def cmd_mqtt_disconnect(wp):
if mqtt_client.is_connected():
_LOGGER.info(f"Disconnecting from MQTT server ...")
mqtt_client.disconnect()

def cmd_set(wp, args):
if len(args) != 2:
_LOGGER.error(f"Wrong number of arguments: set <property> <value>")
elif not args[0] in wp.allProps:
_LOGGER.error(f"Unknown property: {args[0]}")
else:
if args[1].lower() in ["false","true"]:
v=json.loads(args[1].lower())
elif str(args[1]).isnumeric():
v=int(args[1])
elif str(args[1]).isdecimal():
v=float(args[1])
else:
v=str(args[1])
wp.send_update(args[0],v)

def cmd_watch_message(wp, name):
if len(watching_messages) == 0:
wp.register_message_callback(watch_messages)
if name not in watching_messages:
watching_messages.append(name)

def cmd_watch_property(wp, name):
if len(watching_properties) == 0:
wp.register_property_callback(watch_properties)
if name not in watching_properties:
watching_properties.append(name)

def cmd_watch(wp, args):
if len(args) != 2:
_LOGGER.error(f"Wrong number of arguments!")
elif args[0] == "message" and not args[1] in wpi['messages']:
_LOGGER.error(f"Unknown message type: {args[1]}")
elif args[0] == "message":
cmd_watch_message(wp, args[1])
elif args[0] == "property" and not args[1] in wp.allProps:
_LOGGER.error(f"Unknown property: {args[1]}")
elif args[0] == "property":
cmd_watch_property(wp, args[1])
else:
_LOGGER.error(f"Unknown watch type: {args[0]}")

def cmd_unwatch(wp,args):
if len(args) != 2:
_LOGGER.error(f"Wrong number of arguments!")
elif args[0] == "message" and not args[1] in watching_messages:
_LOGGER.warning(f"Message of type '{args[1]}' is not watched")
elif args[0] == "message":
watching_messages.remove(args[1])
if len(watching_messages) == 0:
wp.unregister_message_callback()
elif args[0] == "property" and not args[1] in watching_properties:
_LOGGER.warning(f"Property with name '{args[1]}' is not watched")
elif args[0] == "property":
watching_properties.remove(args[1])
if len(watching_properties) == 0:
wp.unregister_property_callback()
else:
_LOGGER.error(f"Unknown watch type: {args[0]}")

def process_command(wp, wpi, cmdline):
"""Process a Wattpilot shell command"""
Expand All @@ -57,19 +145,16 @@ def process_command(wp, wpi, cmdline):
elif cmd == "exit":
exit = True
elif (cmd == 'get'):
if len(args) != 1:
print(f"Wrong number of arguments: get <property>")
elif not args[0] in wp.allProps:
print(f"Unknown property: {args[0]}")
else:
print(wp.allProps[args[0]])
cmd_get(wp,args)
elif (cmd == 'help'):
print("Wattpilot Shell Commands:")
print(" dump: Dump all property values")
print(" exit: Exit the shell")
print(" get <name>: Get a property value")
print(" info: Print most important infos")
print(" list: List all known property keys")
print(" list [propsearch]: List all properties (starting with propsearch if given)")
print(" mqtt-connect <host> <port>: Connect to MQTT server")
print(" mqtt-disconnect: Disconnect from MQTT server")
print(" set <name> <value>: Set a property value")
print(" watch message <type>: Watch message of given message type")
print(" watch property <name>: Watch value changes of given property name")
Expand All @@ -79,60 +164,23 @@ def process_command(wp, wpi, cmdline):
print(wp)
elif (cmd == 'list'):
print(f"List of available properties:")
for propName, value in sorted(wp.allProps.items()):
printPropInfo(wpi,propName,value)
if len(args)==1:
props = {k:v for k,v in wp.allProps.items() if k.startswith(args[0])}
else:
props = wp.allProps
for propName, value in sorted(props.items()):
print_prop_info(wpi,propName,value)
print()
elif (cmd == 'mqtt-connect'):
cmd_mqtt_connect(wp, args)
elif (cmd == 'mqtt-disconnect'):
cmd_mqtt_disconnect(wp)
elif (cmd == 'set'):
if len(args) != 2:
print(f"Wrong number of arguments: set <property> <value>")
elif not args[0] in wp.allProps:
print(f"Unknown property: {args[0]}")
else:
if args[1].lower() in ["false","true"]:
v=json.loads(args[1].lower())
elif str(args[1]).isnumeric():
v=int(args[1])
elif str(args[1]).isdecimal():
v=float(args[1])
else:
v=str(args[1])
wp.send_update(args[0],v)
cmd_set(wp, args)
elif (cmd == 'watch'):
if len(args) != 2:
print(f"Wrong number of arguments!")
elif args[0] == "message" and not args[1] in wpi['messages']:
print(f"Unknown message type: {args[1]}")
elif args[0] == "message":
if len(watching_messages) == 0:
wp.register_message_callback(watch_messages)
if args[1] not in watching_messages:
watching_messages.append(args[1])
elif args[0] == "property" and not args[1] in wp.allProps:
print(f"Unknown property: {args[1]}")
elif args[0] == "property":
if len(watching_properties) == 0:
wp.register_property_callback(watch_properties)
if args[1] not in watching_properties:
watching_properties.append(args[1])
else:
print(f"Unknown watch type: {args[0]}")
cmd_watch(wp,args)
elif (cmd == 'unwatch'):
if len(args) != 2:
print(f"Wrong number of arguments!")
elif args[0] == "message" and not args[1] in watching_messages:
print(f"Message of type '{args[1]}' is not watched")
elif args[0] == "message":
watching_messages.remove(args[1])
if len(watching_messages) == 0:
wp.unregister_message_callback()
elif args[0] == "property" and not args[1] in watching_properties:
print(f"Property with name '{args[1]}' is not watched")
elif args[0] == "property":
watching_properties.remove(args[1])
if len(watching_properties) == 0:
wp.unregister_property_callback()
else:
print(f"Unknown watch type: {args[0]}")
cmd_unwatch(wp,args)
else:
print(f"Unknown command: {cmd}")
return exit
Expand All @@ -154,26 +202,70 @@ def wait_timeout(fn, timeout):
within_timeout = False
return within_timeout

# MQTT functions

def mqtt_message(wp,wsapp,msg,msg_json):
if mqtt_client == None:
_LOGGER.debug(f"Skipping MQTT message publishing.")
return
if msg.type not in ["fullStatus", "deltaStatus"]:
_LOGGER.debug(f"Skipping MQTT message publishing of type {msg.type}.")
return
msg_dict = json.loads(msg_json)
if MQTT_PUBLISH_MESSAGES == "true":
message_topic = MQTT_MESSAGE_TOPIC_PATTERN \
.replace("{baseTopic}",MQTT_BASE_TOPIC) \
.replace("{serialNumber}",wp.serial) \
.replace("{messageType}",msg.type)
mqtt_client.publish(message_topic, msg_json)
if MQTT_PUBLISH_PROPERTIES == "true":
for prop_name, value in msg_dict["status"].items():
if MQTT_WATCH_PROPERTIES == [] or prop_name in MQTT_WATCH_PROPERTIES:
_LOGGER.debug(f"Publishing property '{prop_name}' with value '{value}' to MQTT ...")
property_topic = MQTT_PROPERTY_READ_TOPIC_PATTERN \
.replace("{baseTopic}",MQTT_BASE_TOPIC) \
.replace("{serialNumber}",wp.serial) \
.replace("{propName}",prop_name)
mqtt_client.publish(property_topic, json.dumps(value))

# Timeouts:
connect_timeout = 10
initialized_timeout = 10
# Timeout config:
WATTPILOT_CONNECT_TIMEOUT = int(os.environ.get('WATTPILOT_CONNECT_TIMEOUT','30'))
WATTPILOT_INITIALIZED_TIMEOUT = int(os.environ.get('WATTPILOT_INITIALIZED_TIMEOUT','30'))

# Globals:
watching_properties = []
watching_messages = []
mqtt_client = None

# MQTT config:
MQTT_ENABLED = os.environ.get('MQTT_ENABLED','false')
if MQTT_ENABLED == "true":
MQTT_CLIENT_ID = os.environ.get('MQTT_CLIENT_ID','wattpilot2mqtt')
MQTT_HA_DISCOVERY_ENABLED = os.environ.get('MQTT_HA_DISCOVERY_ENABLED','false')
MQTT_HOST = os.environ.get('MQTT_HOST')
MQTT_PORT = int(os.environ.get('MQTT_PORT','1883'))
MQTT_BASE_TOPIC = os.environ.get('MQTT_BASE_TOPIC','wattpilot')
MQTT_PUBLISH_MESSAGES = os.environ.get('MQTT_PUBLISH_MESSAGES','true')
MQTT_MESSAGE_TOPIC_PATTERN = os.environ.get('MQTT_MESSAGE_TOPIC_PATTERN','{baseTopic}/{serialNumber}/messages/{messageType}')
MQTT_PUBLISH_PROPERTIES = os.environ.get('MQTT_PUBLISH_PROPERTIES','false')
MQTT_WATCH_PROPERTIES = os.environ.get('MQTT_WATCH_PROPERTIES','amp car fna lmo sse').split(sep=' ')
MQTT_PROPERTY_READ_TOPIC_PATTERN = os.environ.get('MQTT_PROPERTY_READ_TOPIC_PATTERN','{baseTopic}/{serialNumber}/properties/{propName}')
MQTT_PROPERTY_SET_TOPIC_PATTERN = os.environ.get('MQTT_PROPERTY_SET_TOPIC_PATTERN','{baseTopic}/{serialNumber}/properties/{propName}/set')
mqtt_client = mqtt.Client(MQTT_CLIENT_ID)
mqtt_client.connect(MQTT_HOST, MQTT_PORT)


# Set debug level:
logging.basicConfig(level='WARNING')
logging.basicConfig(level=os.environ.get('WATTPILOT_DEBUG_LEVEL','INFO'))

# Setup readline
readline.parse_and_bind("tab: complete")
readline.set_completer(complete)

# Commandline argument parser:
parser = argparse.ArgumentParser()
parser.add_argument("ip", help = "IP of Wattpilot Device")
parser.add_argument("password", help = "Password of Wattpilot")
parser.add_argument("ip", help = "IP of Wattpilot Device", nargs="?")
parser.add_argument("password", help = "Password of Wattpilot", nargs="?")
parser.add_argument("cmdline", help = "Optional shell command", nargs="?")
args = parser.parse_args()

Expand All @@ -186,16 +278,31 @@ def wait_timeout(fn, timeout):
with open("wattpilot.yaml", 'r') as stream:
try:
wpi=yaml.safe_load(stream)
wpi_messages = dict(zip(
[x["key"] for x in wpi["messages"]],
[x for x in wpi["messages"]],
))
wpi_properties = dict(zip(
[x["key"] for x in wpi["properties"]],
[x for x in wpi["properties"]],
))
except yaml.YAMLError as exc:
print(exc)

# Connect to Wattpilot:
wp = wattpilot.Wattpilot(args.ip,args.password)
ip = args.ip or os.environ.get('WATTPILOT_HOST')
password = args.password or os.environ.get('WATTPILOT_PASSWORD')
wp = wattpilot.Wattpilot(ip,password)

# Enable MQTT integration:
if MQTT_ENABLED == "true":
_LOGGER.debug(f"Registering message callback for MQTT integration.")
wp.register_message_callback(mqtt_message)
wp.connect()

# Wait for connection and initializon:
wait_timeout(lambda: wp.connected, connect_timeout) or exit("ERROR: Timeout while connecting to Wattpilot!")
wait_timeout(lambda: wp.allPropsInitialized, initialized_timeout) or exit("ERROR: Timeout while waiting for property initialization!")
wait_timeout(lambda: wp.connected, WATTPILOT_CONNECT_TIMEOUT) or exit("ERROR: Timeout while connecting to Wattpilot!")
wait_timeout(lambda: wp.allPropsInitialized, WATTPILOT_INITIALIZED_TIMEOUT) or exit("ERROR: Timeout while waiting for property initialization!")

# Process commands:
if args.cmdline:
Expand Down
Loading

0 comments on commit ae3e37c

Please sign in to comment.