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

feat(shell): add mqtt + env config support #6

Merged
merged 3 commits into from
Apr 15, 2022
Merged
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
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