diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6b43d8e --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +text=auto diff --git a/.gitignore b/.gitignore index 8bd5644..1e0c1aa 100755 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ install_pyHPSU install .vscode/settings.json -.vscode/launch.json \ No newline at end of file +.vscode/launch.json +.vscode/.ropeproject/** diff --git a/HPSU/HPSU.py b/HPSU/HPSU.py index a8356e2..2716a08 100644 --- a/HPSU/HPSU.py +++ b/HPSU/HPSU.py @@ -42,14 +42,14 @@ def __init__(self, logger=None, driver=None, port=None, cmd=None, lg_code=None): if not self.listCommands: #if we don't get a dict with commands # get language, if non given, take it from the system - LANG_CODE = lg_code.upper()[0:2] if lg_code else locale.getdefaultlocale()[0].split('_')[0].upper() + LANG_CODE = lg_code[0:2] if lg_code else locale.getdefaultlocale()[0].split('_')[0].upper() hpsuDict = {} # read the translation file. if it doesn't exist, take the english one command_translations_hpsu = '%s/commands_hpsu_%s.csv' % (self.pathCOMMANDS, LANG_CODE) if not os.path.isfile(command_translations_hpsu): command_translations_hpsu = '%s/commands_hpsu_%s.csv' % (self.pathCOMMANDS, "EN") - self.logger.info("loading command traslations file: "+command_translations_hpsu) + self.logger.info("HPSU %s, loading command traslations file: %s" % (cmd, command_translations_hpsu)) # check, if commands are json or csv # read all known commands with open(command_translations_hpsu, 'rU',encoding='utf-8') as csvfile: @@ -63,7 +63,7 @@ def __init__(self, logger=None, driver=None, port=None, cmd=None, lg_code=None): # read all known commands command_details_hpsu = '%s/commands_hpsu.json' % self.pathCOMMANDS - self.logger.info("loading command details file: "+command_details_hpsu) + self.logger.info("HPSU %s, loading command details file: %s" % (cmd, command_details_hpsu)) with open(command_details_hpsu, 'rU',encoding='utf-8') as jsonfile: self.all_commands = json.load(jsonfile) self.command_dict=self.all_commands["commands"] @@ -220,6 +220,8 @@ def umConversion(self, cmd, response, verbose): resp = str(response["resp"]) else: resp = str(response["resp"]) + # TODO replaces resp with the decoded value, major breaking change to be discussed + # meanwhile, a 'desc' field is added to the output json #if cmd["value_code"]: # resp=cmd["value_code"][resp] return resp diff --git a/HPSU/canpi.py b/HPSU/canpi.py index 394caa5..59bc841 100644 --- a/HPSU/canpi.py +++ b/HPSU/canpi.py @@ -19,6 +19,7 @@ class CanPI(object): def __init__(self, hpsu=None): self.hpsu = hpsu try: + # TODO evaluate can.ThreadSafeBus self.bus = can.interface.Bus(channel='can0', bustype='socketcan_native') except Exception: self.hpsu.logger.exception('Error opening bus can0') @@ -77,7 +78,7 @@ def sendCommandWithID(self, cmd, setValue=None, priority=1): command = command+" %02X %02X" % (setValue >> 8, setValue & 0xff) if cmd["type"] == "value": setValue = int(setValue) - command = command+" 00 %02X" % (setValue) + command = command+" %02X %02X" % (setValue >> 8, setValue & 0xff) msg_data = [int(r, 16) for r in command.split(" ")] notTimeout = True @@ -86,6 +87,7 @@ def sendCommandWithID(self, cmd, setValue=None, priority=1): try: msg = can.Message(arbitration_id=receiver_id, data=msg_data, extended_id=False, dlc=7) self.bus.send(msg) + self.hpsu.logger.debug("CanPI, %s sent: %s" % (cmd['name'], msg)) except Exception: self.hpsu.logger.exception('Error sending msg') @@ -107,17 +109,17 @@ def sendCommandWithID(self, cmd, setValue=None, priority=1): if (msg_data[2] == 0xfa and msg_data[3] == rcBUS.data[3] and msg_data[4] == rcBUS.data[4]) or (msg_data[2] != 0xfa and msg_data[2] == rcBUS.data[2]): rc = "%02X %02X %02X %02X %02X %02X %02X" % (rcBUS.data[0], rcBUS.data[1], rcBUS.data[2], rcBUS.data[3], rcBUS.data[4], rcBUS.data[5], rcBUS.data[6]) notTimeout = False - #print("got: " + str(rc)) + self.hpsu.logger.debug("CanPI %s, got: %s" % (cmd['name'], str(rc))) else: - self.hpsu.logger.error('SEND:%s' % (str(msg_data))) - self.hpsu.logger.error('RECV:%s' % (str(rcBUS.data))) + self.hpsu.logger.warning('CanPI %s, SEND: %s' % (cmd['name'], str(msg_data))) + self.hpsu.logger.warning('CanPI %s, RECV: %s' % (cmd['name'], str(rcBUS.data))) else: - self.hpsu.logger.error('Not aquired bus') + self.hpsu.logger.warning('CanPI %s, Not aquired bus' % cmd['name']) if notTimeout: - self.hpsu.logger.warning('msg not sync, retry: %s' % i) + self.hpsu.logger.warning('CanPI %s, msg not sync, retry: %s' % (cmd['name'], i)) if i >= self.retry: - self.hpsu.logger.error('msg not sync, timeout') + self.hpsu.logger.error('CanPI %s, msg not sync, timeout' % cmd['name']) notTimeout = False rc = "KO" diff --git a/HPSU/cantcp.py b/HPSU/cantcp.py index 9d1050d..b423e74 100644 --- a/HPSU/cantcp.py +++ b/HPSU/cantcp.py @@ -9,8 +9,6 @@ import uuid import json -SocketPort = 7060 - class CanTCP(object): sock = None hpsu = None diff --git a/HPSU/plugins/mqtt.py b/HPSU/plugins/mqtt.py index 86bf37c..fd4da6f 100644 --- a/HPSU/plugins/mqtt.py +++ b/HPSU/plugins/mqtt.py @@ -33,65 +33,41 @@ def __init__(self, hpsu=None, logger=None, config_file=None): else: sys.exit(9) - # MQTT hostname or IP - if self.config.has_option('MQTT', 'BROKER'): - self.brokerhost = self.config['MQTT']['BROKER'] - else: - self.brokerhost = 'localhost' - - # MQTT broker port - if self.config.has_option('MQTT', 'PORT'): - self.brokerport = int(self.config['MQTT']['PORT']) - else: - self.brokerport = 1883 - - # MQTT client name - if self.config.has_option('MQTT', 'CLIENTNAME'): - self.clientname = self.config['MQTT']['CLIENTNAME'] - else: - self.clientname = 'rotex' - # MQTT Username - if self.config.has_option('MQTT', 'USERNAME'): - self.username = self.config['MQTT']['USERNAME'] - else: - self.username = None - self.hpsu.logger.error("Username not set!!!!!") - - #MQTT Password - if self.config.has_option('MQTT', "PASSWORD"): - self.password = self.config['MQTT']['PASSWORD'] - else: - self.password="None" - - #MQTT Prefix - if self.config.has_option('MQTT', "PREFIX"): - self.prefix = self.config['MQTT']['PREFIX'] - else: - self.prefix = "" - - #MQTT QOS - if self.config.has_option('MQTT', "QOS"): - self.qos = self.config['MQTT']['QOS'] - else: - self.qos = "0" - + # object to store entire MQTT config section + self.mqtt_config = self.config['MQTT'] + self.brokerhost = self.mqtt_config.get('BROKER', 'localhost') + self.brokerport = self.mqtt_config.getint('PORT', 1883) + self.clientname = self.mqtt_config.get('CLIENTNAME', 'rotex') + self.username = self.mqtt_config.get('USERNAME', None) + if self.username is None: + self.logger.error("Username not set!!!!!") + self.password = self.mqtt_config.get('PASSWORD', "NoPasswordSpecified") + self.prefix = self.mqtt_config.get('PREFIX', "") + self.qos = self.mqtt_config.getint('QOS', 0) + # every other value implies false + self.retain = self.mqtt_config.get('RETAIN', "NOT TRUE") == "True" + # every other value implies false + self.addtimestamp = self.mqtt_config.get('ADDTIMESTAMP', "NOT TRUE") == "True" + + self.logger.info("configuration parsing complete") + + # no need to create a different client name every time, because it only publish + self.logger.info("creating new mqtt client instance: " + self.clientname) self.client=mqtt.Client(self.clientname) - #self.client.on_publish = self.on_publish() + self.client.on_publish = self.on_publish if self.username: self.client.username_pw_set(self.username, password=self.password) self.client.enable_logger() - - #def on_publish(self,client,userdata,mid): - # self.hpsu.logger.debug("data published, mid: " + str(mid) + "\n") - # pass - + def on_publish(self,client,userdata,mid): + self.hpsu.logger.debug("mqtt output plugin data published, mid: " + str(mid)) def pushValues(self, vars=None): - + #self.msgs=[] for r in vars: + self.logger.info("connecting to broker: " + self.brokerhost + ", port: " + str(self.brokerport)) self.client.connect(self.brokerhost, port=self.brokerport) msgs=[] if self.prefix: @@ -101,10 +77,4 @@ def pushValues(self, vars=None): ret=self.client.publish(r['name'],payload=r['resp'], qos=int(self.qos)) topic=r['name'] msg={'topic':topic,'payload':r['resp'], 'qos':self.qos, 'retain':False} - self.client.disconnect() - - - - - - + self.client.disconnect() \ No newline at end of file diff --git a/HPSU/plugins/mysql.py b/HPSU/plugins/mysql.py index 3ce5e39..0e58a05 100644 --- a/HPSU/plugins/mysql.py +++ b/HPSU/plugins/mysql.py @@ -55,7 +55,7 @@ def __init__(self, hpsu=None, logger=None, config_file=None, config=None): if db_config.has_option('MYSQL','DB_USER'): db_user=db_config['MYSQL']['DB_USER'] else: - self.hpsu.logger.error(("No database user defined in config file.") + self.hpsu.logger.error("No database user defined in config file.") sys.exit(9) if db_config.has_option('MYSQL','DB_PASSWORD'): diff --git a/README.md b/README.md index e378a96..1029814 100755 --- a/README.md +++ b/README.md @@ -151,6 +151,45 @@ The pyHPSUD.py is started via systemd: root@rotex:# systemctl enable hpsud.service root@rotex:# systemctl start hpsud.service +4. MQTT Daemon mode +pyHPSU starts in daemon mode, it subscribe an MQTT topic and listen forever waiting for commands. +MQTT coordinates are specified through configuration file: the same property used by mqtt output plugin plus additional COMMANDTOPIC and STATUSTOPIC. +The daemon subscribe to the topic +` PREFIX / COMMANDTOPIC / +` +and publish to the topic +` PREFIX / ` +publishing to COMMANDTOPIC with value '' or 'read' results in property red from hpsu and published to mqtt (same topics used by mqtt output plugin) +publishing to COMMANDTOPIC with another value results in pyHPSU trying to change that value on specified hpsu property and than re-reading the same property and publishing the obtained value +``` +e.g. + configuration file (e.g. /etc/pyHPSU/pyhpsu.conf) + ... + [MQTT] + BROKER = 192.168.1.94 + PREFIX = myhpsu + COMMANDTOPIC = command + ... + + root@rotex:# pyHPSU.py --mqtt_daemon + + user@anothersystem:# mosquitto_pub -h 192.168.1.94 -t "myhpsu/command/t_dhw" -m read + + publish the current value of t_dhw red from hpsu into the following topic + + myhpsu/status/t_dhw + +e.g. + (with same config) + + root@rotex:# pyHPSU.py --mqtt_daemon -a -o mqtt + + user@anothersystem:# mosquitto_pub -h 192.168.1.94 -t "myhpsu/command/t_flow_day" -m 29 + + set the parameter t_flow_day to 29°C, meanwhile pyHPSU is running in automatic mode and publishing periodically to the appopriate mqtt topics + + myhpsu/status/t_dhw +``` + Now, you can query multiple values or run multiple pyHPSU.py processes. Simply set as driver HPSUD ("CANTCP") via commandline or the config file (PYHPSU section) i.e. root@rotex:# pyHPSU.py -d HPSUD -c t_dhw_setpoint1 diff --git a/etc/pyHPSU/commands_hpsu.json b/etc/pyHPSU/commands_hpsu.json old mode 100755 new mode 100644 index 148fa18..2ad3411 --- a/etc/pyHPSU/commands_hpsu.json +++ b/etc/pyHPSU/commands_hpsu.json @@ -100,8 +100,8 @@ "unit" : "longint", "type" : "value", "value_code" : { - "0" : "off", - "1" : "on" + "off" : "0", + "on" : "off" } }, "runtime_comp" : { @@ -221,13 +221,13 @@ "unit" : "", "type" : "int", "value_code" : { - "1" : "standby", - "3" : "heat", - "4" : "sink", - "5" : "summer", - "17" : "cool", - "11" : "auto 1", - "12" : "auto 2" + "standby" : "1", + "heat" : "3", + "sink" : "4", + "summer" : "5", + "cool" : "17", + "auto 1" : "11", + "auto 2" : "12" } }, "tvbh2" : { @@ -284,9 +284,9 @@ "unit" : "longint", "type" : "value", "value_code" : { - "0" : "off", - "1" : "activated", - "2" : "only at night" + "off" : "0", + "activated" :"1", + "only at night" : "2" } }, "mode" : { @@ -298,10 +298,10 @@ "unit" : "longint", "type" : "longint", "value_code" : { - "0" : "standby", - "1" : "heating", - "2" : "cooling", - "3" : "3" + "standby" :"0", + "heating" : "1", + "cooling" : "2", + "3": "3" } }, "pump" : { @@ -502,13 +502,13 @@ "unit" : "KW", "type" : "value", "value_code" : { - "0" : " ", - "1" : "4", - "2" : "6", - "3" : "8", - "4" : "11", - "5" : "14", - "6" : "16" + " " : "0", + "4" : "1", + "6" : "2", + "8" : "3", + "11" : "4", + "14" : "5", + "16" : "6" } }, "indoor_unit" : { @@ -520,11 +520,11 @@ "unit" : "longint", "type" : "value", "value_code" : { - "0" : " ", - "1" : "304", - "2" : "308", - "3" : "508", - "4" : "516" + " " : "0", + "304" : "1", + "308" : "2", + "508" : "3", + "516" : "4" } }, "func_heating" : { @@ -545,8 +545,8 @@ "unit" : "longint", "type" : "value", "value_code" : { - "0" : "off", - "1" : "on" + "off" : "0", + "on" : "1" } }, "equi_func" : { @@ -558,8 +558,8 @@ "unit" : "longint", "type" : "value", "value_code" : { - "0" : "off", - "1" : "on" + "off" :"0", + "on" : "1" } }, "smart_grid" : { @@ -571,8 +571,8 @@ "unit" : "longint", "type" : "value", "value_code" : { - "0" : "off", - "1" : "on" + "off" : "0", + "on" : "1" } }, "modus_sg" : { @@ -584,9 +584,9 @@ "unit" : "longint", "type" : "value", "value_code" : { - "0" : "comfort", - "1" : "standard", - "2" : "eco" + "comfort" : "0", + "standard" : "1", + "eco" : "2" } }, "ht_nt_func" : { @@ -598,10 +598,10 @@ "unit" : "longint", "type" : "value", "value_code" : { - "0" : "deactivated", - "1" : "compressor off", - "2" : "compressor off, reserve heating off", - "3" : "all off" + "deactivated" : "0", + "compressor off" : "1", + "compressor off, reserve heating off" : "2", + "all off" : "3" } }, "ht_nt_contact" : { @@ -613,8 +613,8 @@ "unit" : "longint", "type" : "value", "value_code" : { - "0" : "NO", - "1" : "NC" + "NO" : "0", + "NC" : "1" } }, "room_therm" : { @@ -626,8 +626,8 @@ "unit" : "longint", "type" : "value", "value_code" : { - "0" : "off", - "1" : "on" + "off" : "0", + "on" : "1" } }, "interlink" : { @@ -639,8 +639,8 @@ "unit" : "longint", "type" : "value", "value_code" : { - "0" : "off", - "1" : "on" + "off" : "0", + "on" : "1" } }, "air_purge" : { @@ -652,8 +652,8 @@ "unit" : "longint", "type" : "value", "value_code" : { - "0" : "off", - "1" : "on" + "off": "0", + "on" : "1" } }, "max_perf_pump" : { @@ -683,8 +683,8 @@ "unit" : "sval", "type" : "value", "value_code" : { - "4" : "off", - "5" : "on" + "off" : "4", + "on" : "5" } }, "storage_conf" : { @@ -696,9 +696,9 @@ "unit" : "sval", "type" : "value", "value_code" : { - "0" : "off", - "2" : "thermostat", - "4" : "sensor" + "off" : "0", + "thermostat" : "2", + "sensor" : "4" } }, "pres_conf" : { @@ -710,8 +710,8 @@ "unit" : "sval", "type" : "value", "value_code" : { - "10244" : "off", - "26628" : "on" + "off" : "10244", + "on" : "26628" } }, "out_temp_adapt" : { @@ -795,14 +795,14 @@ "unit" : "longint", "type" : "value", "value_code" : { - "0" : "off", - "1" : "on", - "2" : "only at night" + "off" : "0", + "on" : "1", + "only at night" : "2" } }, "aux_fct" : { "name" : "aux_fct", - "command" : "31 00 FA 06 96 00 00", + "command" : "31 00 FA 06 71 00 00", "id" : "190", "divisor" : "1", "writable" : "true", @@ -908,8 +908,8 @@ "unit" : "longint", "type" : "value", "value_code" : { - "0" : "weather controlled", - "1" : "fixed value" + "weather controlled" : "0", + "fixed value" : "1" } }, "t_frost_protect" : { @@ -930,11 +930,11 @@ "unit" : "longint", "type" : "value", "value_code" : { - "0" : "off", - "512" : "low", - "1024" : "normal", - "2048" : "good", - "3072" : "very good" + "off" : "0", + "low" : "512", + "normal" : "1024", + "good" : "2048", + "very good" : "3072" } }, "screed" : { @@ -946,8 +946,8 @@ "unit" : "longint", "type" : "value", "value_code" : { - "0" : "off", - "256" : "on" + "off" : "0", + "on" : "256" } }, "t_screed_day1" : { @@ -1006,7 +1006,7 @@ }, "t_screed_day7" : { "name" : "screed_day7", - "command" : "61 00 FA 0B BD 00 00", + "command" : "61 00 FA 0B BF 00 00", "id" : "190", "divisor" : "10", "writable" : "true", @@ -1141,7 +1141,7 @@ }, "t_screed_day22" : { "name" : "screed_day22", - "command" : "61 00 FA 0B CD 00 00", + "command" : "61 00 FA 0B CE 00 00", "id" : "190", "divisor" : "10", "writable" : "true", @@ -1265,8 +1265,8 @@ "unit" : "longint", "type" : "value", "value_code" : { - "0" : "off", - "256" : "on" + "off" : "0", + "on" :"256" } }, "start_tout_cool" : { @@ -1341,8 +1341,8 @@ "unit" : "longint", "type" : "value", "value_code" : { - "0" : "off", - "1" : "on" + "off" : "0", + "on" : "1" } }, "circ_pump_interval" : { @@ -1363,15 +1363,15 @@ "unit" : "longint", "type" : "value", "value_code" : { - "0" : "off", - "256" : "Monday", - "512" : "Tuesday", - "768" : "Wednesday", - "1024" : "Thursday", - "1280" : "Friday", - "1536" : "Saturday", - "1792" : "Sunday", - "2048" : "Everyday" + "off" : "0", + "monday" : "256", + "tuesday" : "512", + "wednesday" : "768", + "thursday" : "1024", + "friday" : "1280", + "saturday" : "1536", + "sunday" : "1792", + "everyday" : "2048" } }, "anti_leg_time" : { @@ -1419,8 +1419,8 @@ "unit" : "longint", "type" : "value", "value_code" : { - "0" : "off", - "1" : "on" + "off": "0", + "on" : "1" } }, "timer_boh" : { @@ -1452,12 +1452,56 @@ }, "t_room" :{ "name" : "t_room", - "command" : "81 00 FA 00 11 00 00", + "command" : "31 00 FA 00 11 00 00", "id" : "190", "divisor" : "10", "writable" : "false", "unit" : "deg", "type" : "float" + }, + "comp_active" : { + "name" : "comp_active", + "command" : "A1 00 61 00 00 00 00", + "id" : "190", + "divisor" : "1", + "writable" : "false", + "unit" : "", + "type" : "longint", + "value_code" : { + "off" : "0", + "on" : "256" + } + }, + "pump_active" :{ + "name" : "pump_active", + "command" : "A1 00 FA FD AC 00 00", + "id" : "190", + "divisor" : "1", + "writable" : "false", + "unit" : "", + "type" : "longint", + "value_code" : { + "off" : "0", + "on" : "256" + } + }, + "temperature_spread_pwm" : { + "name" : "temperature_spread_pwm", + "command" : "A1 00 FA 06 DB 00 00", + "id" : "190", + "divisor" : "10", + "writable" : "true", + "unit" : "deg", + "type" : "float" + }, + "offset_t_flow" : { + "name" : "offset_t_flow", + "command" : "A1 00 FA 06 E2 00 00", + "id" : "190", + "divisor" : "10", + "writable" : "true", + "unit" : "deg", + "type" : "float" } } } diff --git a/pyHPSU.py b/pyHPSU.py index bd3d450..fa1a7c9 100644 --- a/pyHPSU.py +++ b/pyHPSU.py @@ -2,66 +2,88 @@ # # -*- coding: utf-8 -*- +# config inf conf_file for MQTT Daemon mode (defaults): +# [MQTT] +# BROKER = localhost +# PORT = 1883 +# USERNAME = +# PASSWORD = +# CLIENTNAME = rotex_hpsu +# PREFIX = rotex +# COMMANDTOPIC = command +# STATUSTOPIC = status ...for future use +# QOS = 0 +# ADDTIMESTAMP = False ...whether or not to add timestamp to published values import serial import sys +import traceback #sys.path.append('/usr/share/pyHPSU/HPSU') #sys.path.append('/usr/share/pyHPSU/plugins') import os -import getopt import time import locale +import uuid import importlib import logging from HPSU.HPSU import HPSU +import argparse import configparser import threading -import csv import json - -SocketPort = 7060 +import paho.mqtt.publish as publish +import paho.mqtt.subscribe as subscribe +import paho.mqtt.client as mqtt + +logger = None +n_hpsu = None +# global options object +options = None +mqtt_client = None +mqtt_prefix = None +mqtt_qos = 0 +mqtt_retain = False +mqtt_addtimestamp = False +mqttdaemon_command_topic = "command" +mqttdaemon_status_topic = "status" +# unique randomized value per program execution that can be used where needed +execution_uuid = str(uuid.uuid4())[:8] + +def my_except_hook(exctype, value, traceback): + if exctype == KeyboardInterrupt: + print("Interrupted by user") + else: + sys.__excepthook__(exctype, value, traceback) def main(argv): - cmd = [] - port = None - driver = "PYCAN" - verbose = "1" - show_help = False - output_type = "JSON" - upload = False - lg_code = "EN" + global options + global logger + global n_hpsu + global mqtt_client + global mqtt_prefix + global mqtt_qos + global mqtt_retain + global mqtt_addtimestamp + global mqttdaemon_command_topic + global mqttdaemon_status_topic + + sys.excepthook = my_except_hook + cmd = None languages = ["EN", "IT", "DE"] - logger = None - pathCOMMANDS = "/etc/pyHPSU" - global conf_file - conf_file = None global default_conf_file default_conf_file = "/etc/pyHPSU/pyhpsu.conf" read_from_conf_file=False - global auto global ticker ticker=0 loop=True - auto=False - LOG_LEVEL_STRING = 'DEBUG, INFO, WARNING, ERROR, CRITICAL' - # default to loggin.error if --log_level option not present - desired_log_level = logging.ERROR - # default log to stdout if no file specified - log_handler = logging.StreamHandler() + LOG_LEVEL_LIST = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] # default log formatter log_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') #commands = [] #listCommands = [] global config config = configparser.ConfigParser() - global n_hpsu - env_encoding=sys.stdout.encoding PLUGIN_PATH="/usr/lib/python3/dist-packages/HPSU/plugins" - backup_mode=False - global backup_file - restore_mode=False - global options_list - options_list={} # # get all plugins # @@ -74,163 +96,124 @@ def main(argv): PLUGIN_STRING+=PLUGIN PLUGIN_LIST.append(PLUGIN) + parser = argparse.ArgumentParser(description="pyHPSU is a set of python scripts and other files to read and modify the values\nof the Rotex® HPSU (possibly) also identical heating pumps from Daikin®).", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="- If no command is specified, all commands in dictionary are executed\n- To set a value use :") + parser.add_argument("--dictionary", action="store_true", dest="show_help", help="show complete command dictionary or specific command help") + parser.add_argument('--version', action='version', version='%(prog)s 1.0-BETA1') + parser.add_argument("-a", "--auto", action="store_true", help="do automatic queries") + parser.add_argument("-f", "--config", dest="conf_file", help="Configfile, overrides given commandline arguments") + backup_restore_group = parser.add_mutually_exclusive_group() + backup_restore_group.add_argument("-b", "--backup", dest="backup_file", help="backup configurable settings to file [filename]") + backup_restore_group.add_argument("-r", "--restore", dest="restore_file", help="restore HPSU settings from file [filename]") + parser.add_argument("-g", "--log", dest="log_file", help="set the log to file [filename]") + parser.add_argument("--log_level", choices=LOG_LEVEL_LIST, type=str.upper, default="ERROR", help="set the log level to [" + ", ".join(LOG_LEVEL_LIST) + "]") + parser.add_argument("-l", "--language", dest="lg_code", choices=languages, type=str.upper, default="EN", help="set the language to use [%s], default is \"EN\" " % " ".join(languages)) + parser.add_argument("-d", "--driver", type=str.upper, default="PYCAN", help="driver name: [ELM327, PYCAN, EMU, HPSUD], Default: PYCAN") + parser.add_argument("-c", "--cmd", action="append", help="command: [see commands dictionary]") + parser.add_argument("-o", "--output_type", action="append", type=str.upper, choices=PLUGIN_LIST, help="output type: [" + ", ".join(PLUGIN_LIST) + "] default JSON") + parser.add_argument("-v", "--verbose", default="1", help="verbosity: [1, 2] default 1") + parser.add_argument("-p", "--port", help="port (eg COM or /dev/tty*, only for ELM327 driver)") + parser.add_argument("--mqtt_daemon", action="store_true", help="set up an mqtt daemon that subscribe to a command topic and executes received command on HPSU") + try: - opts, args = getopt.getopt(argv,"ahc:p:d:v:o:l:g:f:b:r:", ["help", "cmd=", "port=", "driver=", "verbose=", "output_type=", "upload=", "language=", "log=", "log_level=", "config_file="]) - except getopt.GetoptError: - print('pyHPSU.py -d DRIVER -c COMMAND') - print(' ') - print(' -a --auto do automatic queries') - print(' -f --config Configfile, overrides given commandline arguments') - print(' -d --driver driver name: [ELM327, PYCAN, EMU, HPSUD], Default: PYCAN') - print(' -p --port port (eg COM or /dev/tty*, only for ELM327 driver)') - print(' -o --output_type output type: [' + PLUGIN_STRING + '] default JSON') - print(' -c --cmd command: [see commands domain]') - print(' -v --verbose verbosity: [1, 2] default 1') - print(' -l --language set the language to use [%s], default is \"EN\" ' % " ".join(languages)) - print(' -b --backup backup configurable settings to file [filename]') - print(' -r --restore restore HPSU settings from file [filename]') - print(' -g --log set the log to file [filename]') - print(' --log_level set the log level to [' + LOG_LEVEL_STRING + ']') - print(' -h --help show help') - sys.exit(2) - - for opt, arg in opts: - if opt in ("-a", "--auto"): - auto = True - options_list["auto"]="" - - if opt in ("-f", "--config"): - read_from_conf_file = True - conf_file = arg - options_list["config"]=arg - - if opt in ("-b", "--backup"): - backup_mode=True - backup_file = arg - output_type = "BACKUP" - options_list["backup"]=arg - - if opt in ("-r", "--restore"): - restore_mode=True - backup_file = arg - options_list["restore"]=arg - - if opt in ("-h", "--help"): - show_help = True - options_list["help"]="" - - elif opt in ("-d", "--driver"): - driver = arg.upper() - options_list["driver"]=arg.upper() - - elif opt in ("-p", "--port"): - port = arg - options_list["port"]=arg - - elif opt in ("-c", "--cmd"): - cmd.append(arg) - - elif opt in ("-v", "--verbose"): - verbose = arg - options_list["verbose"]="" - - elif opt in ("-o", "--output_type"): - output_type = arg.upper() - options_list["output_type"]=arg.upper() - - elif opt in ("-l", "--language"): - lg_code = arg.upper() - options_list["language"]=arg.upper() - - elif opt in ("-g", "--log"): - log_handler = logging.FileHandler(arg) - options_list["log_file"]=arg - - elif opt in ("--log_level"): - if arg == 'DEBUG': - desired_log_level = logging.DEBUG - elif arg == 'INFO': - desired_log_level = logging.INFO - elif arg == 'WARNING': - desired_log_level = logging.WARNING - elif arg == 'ERROR': - desired_log_level = logging.ERROR - elif arg == 'CRITICAL': - desired_log_level = logging.CRITICAL - else: - print("Error, " + arg + " is not a valid value for log_level option, use [" + LOG_LEVEL_STRING + "]") - sys.exit(2) - options_list["cmd"]=cmd - - # if no log file has been specified and driver is HPSUD then log nothing - if options_list.get("log_file") is None and driver == "HPSUD": - log_handler = logging.NullHandler + options = parser.parse_args() + except IOError as e: + parser.error(e) + if (options == None): + print(parser.usage) + exit(0) + else: + # set the default value if no output_type is chosen (not possible with add_argument() because it does + # not detect duplication e.g. when JSON is also chosen) + if options.output_type is None: + options.output_type = ["JSON"] + cmd = [] if options.cmd is None else options.cmd + if options.backup_file is not None: + options.output_type.append("BACKUP") + read_from_conf_file = options.conf_file is not None + # if no log file has been specified and driver is HPSUD then log nothing + if options.log_file is None and options.driver == "HPSUD": + _log_handler = logging.NullHandler + elif options.log_file is not None: + _log_handler = logging.FileHandler(options.log_file) + else: + # default log to stdout if no file specified + _log_handler = logging.StreamHandler() + logger = logging.getLogger('pyhpsu') - log_handler.setFormatter(log_formatter) - logger.addHandler(log_handler) - logger.setLevel(desired_log_level) + _log_handler.setFormatter(log_formatter) + logger.addHandler(_log_handler) + logger.setLevel(options.log_level) - if verbose == "2": + if options.verbose == "2": locale.setlocale(locale.LC_ALL, '') - # config if in auto mode - if auto: - read_from_conf_file=True - conf_file=default_conf_file + # if no config file option is present... + if not read_from_conf_file: + # ...set the default one... + # NOTE: other modules may need to load it later + options.conf_file=default_conf_file + # ...but auto or mqttdaemon mode needs it loaded... + if options.auto or options.mqtt_daemon: + # ...read it + read_from_conf_file=True # get config from file if given.... if read_from_conf_file: - if conf_file==None: - logger.error("please provide a config file") + try: + with open(options.conf_file) as f: + config.read_file(f) + except IOError: + logger.critical("config file not found") sys.exit(9) - else: - try: - with open(conf_file) as f: - config.read_file(f) - except IOError: - logger.error("config file not found") - sys.exit(9) - config.read(conf_file) - if driver=="" and config.has_option('PYHPSU','PYHPSU_DEVICE'): - driver=config['PYHPSU']['PYHPSU_DEVICE'] - if port=="" and config.has_option('PYHPSU','PYHPSU_PORT'): - port=config['PYHPSU']['PYHPSU_PORT'] - if lg_code=="" and config.has_option('PYHPSU','PYHPSU_LANG'): - lg_code=config['PYHPSU']['PYHPSU_LANG'] - if output_type=="" and config.has_option('PYHPSU','OUTPUT_TYPE'): - output_type=config['PYHPSU']['OUTPUT_TYPE'] - - else: - conf_file=default_conf_file + config.read(options.conf_file) + if options.driver=="" and config.has_option('PYHPSU','PYHPSU_DEVICE'): + options.driver=config['PYHPSU']['PYHPSU_DEVICE'] + if options.port=="" and config.has_option('PYHPSU','PYHPSU_PORT'): + options.port=config['PYHPSU']['PYHPSU_PORT'] + if options.lg_code=="" and config.has_option('PYHPSU','PYHPSU_LANG'): + options.lg_code=config['PYHPSU']['PYHPSU_LANG'].upper() + if len(options.output_type)==0 and config.has_option('PYHPSU','OUTPUT_TYPE'): + options.output_type.append(config['PYHPSU']['OUTPUT_TYPE']) + + # object to store entire MQTT config section + mqtt_config = config['MQTT'] + # MQTT Daemon command topic + mqttdaemon_command_topic = mqtt_config.get('COMMAND', 'command') + # MQTT Daemon status topic + # FIXME to be used for pyHPSU status, including MQTTDAEMON mode, not for actual parameter reading + mqttdaemon_status_topic = mqtt_config.get('STATUS', 'status') + mqtt_brokerhost = mqtt_config.get('BROKER', 'localhost') + mqtt_brokerport = mqtt_config.getint('PORT', 1883) + mqtt_clientname = mqtt_config.get('CLIENTNAME', 'rotex') + mqtt_username = mqtt_config.get('USERNAME', None) + if mqtt_username is None: + logger.error("Username not set!!!!!") + mqtt_password = mqtt_config.get('PASSWORD', "NoPasswordSpecified") + mqtt_prefix = mqtt_config.get('PREFIX', "") + mqtt_qos = mqtt_config.getint('QOS', 0) + # every other value implies false + mqtt_retain = mqtt_config.get('RETAIN', "NOT TRUE") == "True" + # every other value implies false + mqtt_addtimestamp = mqtt_config.get('ADDTIMESTAMP', "NOT TRUE") == "True" + + logger.info("configuration parsing complete") # # now we should have all options...let's check them # - # Check driver - if driver not in ["ELM327", "PYCAN", "EMU", "HPSUD"]: - logger.error("please specify a correct driver [ELM327, PYCAN, EMU, HPSUD] ") - sys.exit(9) - - if driver == "ELM327" and port == "": - logger.error("please specify a correct port for the ELM327 device ") - sys.exit(9) - - # Check output type - if output_type not in PLUGIN_LIST: - logger.error("please specify a correct output_type [" + PLUGIN_STRING + "]") + if options.driver == "ELM327" and options.port == "": + logger.critical("please specify a correct port for the ELM327 device ") sys.exit(9) - # Check Language - if lg_code not in languages: - logger.error("please specify a correct language [%s]" % " ".join(languages)) - sys.exit(9) # ------------------------------------ # try to query different commands in different periods # Read them from config and group them # # create dictionary for the jobs - if auto: + if options.auto: timed_jobs=dict() if read_from_conf_file: # if config is read from file if len(config.options('JOBS')): # if there are configured jobs @@ -241,22 +224,22 @@ def main(argv): timed_jobs["timer_" + job_period].append(each_key) # and add the value to this period wanted_periods=list(timed_jobs.keys()) else: - logger.error("please specify a value to query in config file ") + logger.critical("please specify a value to query in config file ") sys.exit(9) # # Print help # - if show_help: - n_hpsu = HPSU(driver=driver, logger=logger, port=port, cmd=cmd, lg_code=lg_code) + if options.show_help: + n_hpsu = HPSU(driver=options.driver, logger=logger, port=options.port, cmd=cmd, lg_code=options.lg_code) if len(cmd) == 0: print("List available commands:") print("%20s - %-10s" % ('COMMAND', 'LABEL')) print("%20s---%-10s" % ('------------', '----------')) for cmd in sorted(n_hpsu.command_dict) : - try: + if 'label' in n_hpsu.command_dict[cmd]: print("%20s - %-10s" % (n_hpsu.command_dict[cmd]['name'], (n_hpsu.command_dict[cmd]['label']) + ('' if n_hpsu.command_dict[cmd]['writable']=='true' else ' (readonly)'))) - except KeyError: + else: error_message = """!!!!!! No translation for "%12s" !!!!!!!""" % (n_hpsu.command_dict[cmd]['name']) print(error_message) logger.error(error_message) @@ -271,7 +254,33 @@ def main(argv): # now its time to call the hpsu and do the REAL can query # and handle the data as configured # - if auto and not backup_mode: + if options.mqtt_daemon: + _mqttdaemon_clientname = mqtt_clientname + "-mqttdaemon-" + execution_uuid + logger.info("creating new mqtt client instance: " + _mqttdaemon_clientname) + # a different client name because otherwise mqtt output plugin closes this connection, too + mqtt_client = mqtt.Client(_mqttdaemon_clientname) + if mqtt_username: + mqtt_client.username_pw_set(mqtt_username, password=mqtt_password) + mqtt_client.enable_logger() + + mqtt_client.on_message=on_mqtt_message + logger.info("connecting to broker: " + mqtt_brokerhost + ", port: " + str(mqtt_brokerport)) + mqtt_client.connect(mqtt_brokerhost, mqtt_brokerport) + + command_topic = mqtt_prefix + "/" + mqttdaemon_command_topic + "/+" + logger.info("Subscribing to command topic: " + command_topic) + mqtt_client.subscribe(command_topic) + + # this blocks execution + #mqtt_client.loop_forever() + if options.auto: + mqtt_client.loop_start() + + # if a backup file is specified we are in backup mode + if options.backup_file is not None: + n_hpsu = HPSU(driver=options.driver, logger=logger, port=options.port, cmd=cmd, lg_code=options.lg_code) + read_can(n_hpsu.backup_commands, options.verbose, options.output_type) + elif options.auto: while loop: ticker+=1 collected_cmds=[] @@ -281,36 +290,40 @@ def main(argv): for job in timed_jobs[period_string]: collected_cmds.append(str(job)) if len(collected_cmds): - n_hpsu = HPSU(driver=driver, logger=logger, port=port, cmd=collected_cmds, lg_code=lg_code) - exec('thread_%s = threading.Thread(target=read_can, args=(driver,logger,port,collected_cmds,lg_code,verbose,output_type))' % (period)) + n_hpsu = HPSU(driver=options.driver, logger=logger, port=options.port, cmd=collected_cmds, lg_code=options.lg_code) + exec('thread_%s = threading.Thread(target=read_can, args=(collected_cmds,options.verbose,options.output_type))' % (period)) exec('thread_%s.start()' % (period)) time.sleep(1) - elif backup_mode: - n_hpsu = HPSU(driver=driver, logger=logger, port=port, cmd=cmd, lg_code=lg_code) - read_can(driver, logger, port, n_hpsu.backup_commands, lg_code,verbose,output_type) - elif restore_mode: + # if a restore file is specified we are in restore mode + elif options.restore_file is not None: restore_commands=[] try: - with open(backup_file, 'rU') as jsonfile: + with open(options.restore_file, 'rU') as jsonfile: restore_settings=json.load(jsonfile) for command in restore_settings: restore_commands.append(str(command["name"]) + ":" + str(command["resp"])) - n_hpsu = HPSU(driver=driver, logger=logger, port=port, cmd=restore_commands, lg_code=lg_code) - read_can(driver, logger, port, restore_commands, lg_code,verbose,output_type) + n_hpsu = HPSU(driver=options.driver, logger=logger, port=options.port, cmd=restore_commands, lg_code=options.lg_code) + read_can(restore_commands, options.verbose, options.output_type) except FileNotFoundError: logger.error("No such file or directory!!!") sys.exit(1) - else: - n_hpsu = HPSU(driver=driver, logger=logger, port=port, cmd=cmd, lg_code=lg_code) - read_can(driver, logger, port, cmd, lg_code,verbose,output_type) + # FIXME if no command is specified and mqttdaemon mode is active, don't query all the commands (this has to be discussed) + elif not (len(cmd)==0 and options.mqtt_daemon): + n_hpsu = HPSU(driver=options.driver, logger=logger, port=options.port, cmd=cmd, lg_code=options.lg_code) + read_can(cmd, options.verbose, options.output_type) + + # if we reach this point (the end), we are not in auto mode so the loop is not started + if mqtt_client is not None: + mqtt_client.loop_forever() -def read_can(driver,logger,port,cmd,lg_code,verbose,output_type): - global backup_file - # really needed? Driver is checked above - #if not driver: - # print("Error, please specify driver [ELM327 or PYCAN, EMU, HPSUD]") - # sys.exit(9) + +def read_can(cmd, verbose, output_type): + global options + global logger + global mqtt_prefix + global mqttdaemon_status_topic + global mqtt_client arrResponse = [] @@ -319,11 +332,15 @@ def read_can(driver,logger,port,cmd,lg_code,verbose,output_type): for i in cmd: if ":" in i and c["name"] == i.split(":")[0]: setValue = i.split(":")[1] + if c["writable"] != "true": + logger.critical(c["name"] + " is a readonly command") + sys.exit(9) if not c["type"] == "value": setValue = float(setValue)*float(c["divisor"]) else: - logger.error('type "value" not implemented since yet') - return + if not setValue.isdigit(): + key=str(setValue) + setValue=c["value_code"][key] i = 0 while i <= 3: @@ -331,10 +348,22 @@ def read_can(driver,logger,port,cmd,lg_code,verbose,output_type): if rc != "KO": i = 4 if not setValue: - response = n_hpsu.parseCommand(cmd=c, response=rc, verbose=verbose) - resp = n_hpsu.umConversion(cmd=c, response=response, verbose=verbose) - - arrResponse.append({"name":c["name"], "resp":resp, "timestamp":response["timestamp"]}) + response = n_hpsu.parseCommand(cmd=c, response=rc, verbose=options.verbose) + resp = n_hpsu.umConversion(cmd=c, response=response, verbose=options.verbose) + + if "value_code" in c: + # FIXME special treatment is needed for commands sharing the same byte 'ouside_conf' and 'storage' conf + # while not fixed a warning is raised + # e.g. HPSU returns a value of 26628 for both of them and the two values have to be extracted from + # different part of the number + if resp in dict(map(reversed, c["value_code"].items())): + _resp_description = dict(map(reversed, c["value_code"].items()))[resp] + else: + _resp_description = 'unable to decode value ' + resp + logger.warning("command \"" + c["name"] + "\" " + _resp_description) + arrResponse.append({"name":c["name"], "resp":resp, "timestamp":response["timestamp"], "desc":_resp_description}) + else: + arrResponse.append({"name":c["name"], "resp":resp, "timestamp":response["timestamp"]}) else: i += 1 time.sleep(2.0) @@ -342,30 +371,86 @@ def read_can(driver,logger,port,cmd,lg_code,verbose,output_type): if i == 4: logger.error('command %s failed' % (c["name"])) - if output_type == "JSON": - if len(arrResponse)!=0: - print(arrResponse) - elif output_type == "CSV": - for r in arrResponse: - print("%s,%s,%s" % (r["timestamp"], r["name"], r["resp"])) - elif output_type == "BACKUP": - error_message = "Writing Backup to " + str(backup_file) - print(error_message) - logger.info(error_message) - - try: - with open(backup_file, 'w') as outfile: - json.dump(arrResponse, outfile, sort_keys = True, indent = 4, ensure_ascii = False) - except FileNotFoundError: - error_message = "No such file or directory!!!" + for output_type_name in output_type: + if output_type_name == "JSON": + if len(arrResponse)!=0: + print(arrResponse) + elif output_type_name == "CSV": + for r in arrResponse: + print("%s,%s,%s" % (r["timestamp"], r["name"], r["resp"])) + elif output_type_name == "BACKUP": + error_message = "Writing Backup to " + str(options.backup_file) print(error_message) - logger.error(error_message) - sys.exit(1) + logger.info(error_message) + + try: + with open(options.backup_file, 'w') as outfile: + json.dump(arrResponse, outfile, sort_keys = True, indent = 4, ensure_ascii = False) + except FileNotFoundError: + error_message = "No such file or directory!!!" + print(error_message) + logger.error(error_message) + sys.exit(1) + elif output_type_name == "MQTTDAEMON": + for r in arrResponse: + if mqtt_addtimestamp: + # use the same format as JSON output + # with timestamp included, retain=true become more interesting + if "desc" in r: + mqtt_client.publish(mqtt_prefix + "/" + r["name"], "{'name': '%s', 'resp': '%s', 'timestamp': %s, 'desc': '%s'}" % (r["name"], r["resp"], r["timestamp"], r["desc"]), qos=mqtt_qos, retain=mqtt_retain) + else: + mqtt_client.publish(mqtt_prefix + "/" + r["name"], "{'name': '%s', 'resp': '%s', 'timestamp': %s}" % (r["name"], r["resp"], r["timestamp"]), qos=mqtt_qos, retain=mqtt_retain) + else: + mqtt_client.publish(mqtt_prefix + "/" + r["name"], r["resp"], qos=mqtt_qos, retain=mqtt_retain) + + else: + module_name=output_type_name.lower() + module = importlib.import_module("HPSU.plugins." + module_name) + hpsu_plugin = module.export(hpsu=n_hpsu, logger=logger, config_file=options.conf_file) + hpsu_plugin.pushValues(vars=arrResponse) + +def on_disconnect(client, userdata, rc=0): + logger.debug("mqtt disConnected: result code " + str(rc)) + client.loop_stop() + +def on_mqtt_message(client, userdata, message): + global options + global logger + global n_hpsu + + logger.debug("complete topic: " + message.topic) + mqtt_command = message.topic.split('/')[-1] + logger.debug("command topic: " + mqtt_command) + mqtt_value = str(message.payload.decode("utf-8")) + logger.debug("value: " + mqtt_value) + if mqtt_value == '' or mqtt_value == None or mqtt_value == "read": + hpsu_command_string = mqtt_command else: - module_name=output_type.lower() - module = importlib.import_module("HPSU.plugins." + module_name) - hpsu_plugin = module.export(hpsu=n_hpsu, logger=logger, config_file=conf_file) - hpsu_plugin.pushValues(vars=arrResponse) + hpsu_command_string = mqtt_command + ":" + mqtt_value + hpsu_command_list = [hpsu_command_string] + logger.info("setup HPSU to accept commands") + n_hpsu = HPSU(driver=options.driver, logger=logger, port=options.port, cmd=hpsu_command_list, lg_code=options.lg_code) + logger.info("send command to hpsu: " + hpsu_command_string) + #exec('thread_mqttdaemon = threading.Thread(target=read_can(hpsu_command_list, options.verbose, ["MQTTDAEMON"]))') + #exec('thread_mqttdaemon.start()') + read_can(hpsu_command_list, options.verbose, ["MQTTDAEMON"]) + # if command was a write, re-read the value from HPSU and publish to MQTT + if ":" in hpsu_command_string: + hpsu_command_string_reread_after_write = hpsu_command_string.split(":")[0] + hpsu_command_string_reread_after_write_list = [hpsu_command_string_reread_after_write] + #exec('thread_mqttdaemon_reread = threading.Thread(target=read_can(hpsu_command_string_reread_after_write_list, options.verbose, ["MQTTDAEMON"]))') + #exec('thread_mqttdaemon_reread.start()') + logger.info("send same command in read mode to hpsu: " + hpsu_command_string) + read_can(hpsu_command_string_reread_after_write_list, options.verbose, ["MQTTDAEMON"]) + # restarts the loop + if options.auto: + mqtt_client.loop_start() if __name__ == "__main__": - main(sys.argv[1:]) + try: + main(sys.argv[1:]) + except Exception as e: + # print complete information + traceback.print_exc() + #print("Exception: {}".format(type(e).__name__)) + #print("Exception message: {}".format(e)) \ No newline at end of file